diff --git a/server/middleware/src/index.ts b/server/middleware/src/index.ts index 33425770bf5..6ed9e98cfe1 100644 --- a/server/middleware/src/index.ts +++ b/server/middleware/src/index.ts @@ -40,3 +40,4 @@ export * from './identity' export * from './pluginConfig' export * from './userStatus' export * from './findSecurity' +export * from './normalizeTx' diff --git a/server/middleware/src/normalizeTx.ts b/server/middleware/src/normalizeTx.ts new file mode 100644 index 00000000000..26d241599c9 --- /dev/null +++ b/server/middleware/src/normalizeTx.ts @@ -0,0 +1,259 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import core, { + type MeasureContext, + type Tx, + type SessionData, + type TxApplyIf, + type Ref, + type Class, + type Doc, + type Space, + type PersonId, + TxProcessor, + type TxWorkspaceEvent, + type OperationDomain, + type TxDomainEvent, + type TxCUD, + type TxModelUpgrade, + type TxCreateDoc, + type TxUpdateDoc, + type TxRemoveDoc, + type TxMixin, + type Mixin +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' + +// Helper types to require update in validation after Tx types are changed +type ExplicitUndefined = { [P in keyof Required]: Exclude | Extract } +type WithIdType = Omit & { _id: I } +type ExplicitTx = WithIdType, Ref> + +/** + * Validates properties in known Tx interfaces and removes unrecognized properties + * @public + */ +export class NormalizeTxMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new NormalizeTxMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: unknown[]): Promise { + const parsedTxes = [] + for (const tx of txes) { + const parsedTx = this.parseTx(tx) + if (parsedTx === undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + parsedTxes.push(parsedTx) + } + return this.provideTx(ctx, parsedTxes) + } + + private checkMeta (meta: unknown): meta is Record | undefined { + if (meta === undefined) { + return true + } + if (meta === null || typeof meta !== 'object') { + return false + } + for (const [, val] of Object.entries(meta)) { + if (typeof val !== 'string' && typeof val !== 'number' && typeof val !== 'boolean') { + return false + } + } + return true + } + + private parseBaseTx (source: unknown): ExplicitTx | undefined { + if (source == null || typeof source !== 'object') { + return undefined + } + const { _class, _id, space, modifiedBy, modifiedOn, createdBy, createdOn, objectSpace, meta } = source as Record< + keyof Tx, + unknown + > + const isTxValid = + typeof _class === 'string' && + typeof _id === 'string' && + typeof space === 'string' && + typeof modifiedBy === 'string' && + typeof modifiedOn === 'number' && + (createdBy === undefined || typeof createdBy === 'string') && + (createdOn === undefined || typeof createdOn === 'number') && + typeof objectSpace === 'string' && + this.checkMeta(meta) + if (!isTxValid) { + return undefined + } + const baseTx: ExplicitTx = { + _class: _class as Ref>, + _id: _id as Ref, + space: space as Ref, + modifiedBy: modifiedBy as PersonId, + modifiedOn, + createdBy: createdBy as PersonId | undefined, + createdOn, + objectSpace: objectSpace as Ref, + meta: meta as Record | undefined + } + return baseTx + } + + private parseTx (source: unknown): Tx | undefined { + const baseTx = this.parseBaseTx(source) + if (baseTx === undefined) { + return undefined + } + + if (TxProcessor.isExtendsCUD(baseTx._class)) { + return this.parseTxCUD(source, baseTx) + } else if (baseTx._class === core.class.TxWorkspaceEvent) { + const { event, params } = source as any + if (typeof event !== 'number') { + return undefined + } + const workspaceEvent: ExplicitTx = Object.assign(baseTx, { + event, + params + }) + return workspaceEvent + } else if (baseTx._class === core.class.TxDomainEvent) { + const { domain, event } = source as any + if (typeof domain !== 'string') { + return undefined + } + const domainEvent: ExplicitTx = Object.assign(baseTx, { + domain: domain as OperationDomain, + event + }) + return domainEvent + } else if (baseTx._class === core.class.TxApplyIf) { + const { scope, match, notMatch, txes, notify, extraNotify, measureName } = source as Record< + keyof TxApplyIf, + unknown + > + const isValid = + (scope === undefined || typeof scope === 'string') && + (match === undefined || Array.isArray(match)) && + (notMatch === undefined || Array.isArray(notMatch)) && + Array.isArray(txes) && + (notify === undefined || typeof notify === 'boolean') && + (extraNotify === undefined || Array.isArray(extraNotify)) && + (measureName === undefined || typeof measureName === 'string') + if (!isValid) { + return undefined + } + const parsedTxes: TxCUD[] = [] + for (const tx of txes) { + const baseChildTx = this.parseBaseTx(tx) + if (baseChildTx === undefined || !TxProcessor.isExtendsCUD(baseChildTx._class)) { + return undefined + } + const parsed = this.parseTxCUD(tx, baseChildTx) + if (parsed === undefined) { + return undefined + } + parsedTxes.push(parsed as TxCUD) + } + const applyIf: ExplicitTx = Object.assign(baseTx, { + scope, + match, + notMatch, + txes: parsedTxes, + notify, + extraNotify, + measureName + }) + return applyIf + } else if (baseTx._class === core.class.TxModelUpgrade) { + const modelUpgrade: TxModelUpgrade = baseTx + return modelUpgrade + } + + return undefined + } + + private parseTxCUD (source: unknown, base: ExplicitTx): ExplicitTx> | undefined { + const { objectId, objectClass, attachedTo, attachedToClass, collection } = source as Record< + keyof TxCUD, + unknown + > + const isValid = + typeof objectId === 'string' && + typeof objectClass === 'string' && + (attachedTo === undefined || typeof attachedTo === 'string') && + (attachedToClass === undefined || typeof attachedToClass === 'string') && + (collection === undefined || typeof collection === 'string') + if (!isValid) { + return undefined + } + const baseCUD: ExplicitTx> = Object.assign(base, { + objectId: objectId as Ref, + objectClass: objectClass as Ref>, + attachedTo: attachedTo as Ref, + attachedToClass: attachedToClass as Ref>, + collection + }) + if (baseCUD._class === core.class.TxCreateDoc) { + const { attributes } = source as Record, unknown> + if (typeof attributes !== 'object' || attributes === null) { + return undefined + } + const createDoc: ExplicitTx> = Object.assign(baseCUD, { attributes }) + return createDoc + } else if (baseCUD._class === core.class.TxUpdateDoc) { + const { operations, retrieve } = source as Record, unknown> + if ( + typeof operations !== 'object' || + operations === null || + (retrieve !== undefined && typeof retrieve !== 'boolean') + ) { + return undefined + } + const updateDoc: ExplicitTx> = Object.assign(baseCUD, { operations, retrieve }) + return updateDoc + } else if (baseCUD._class === core.class.TxRemoveDoc) { + const removeDoc: ExplicitTx> = baseCUD + return removeDoc + } else if (baseCUD._class === core.class.TxMixin) { + const { mixin, attributes } = source as Record, unknown> + if (typeof mixin !== 'string' || typeof attributes !== 'object' || attributes === null) { + return undefined + } + const txMixin: ExplicitTx> = Object.assign(baseCUD, { + mixin: mixin as Ref>, + attributes + }) + return txMixin + } else { + return undefined + } + } +} diff --git a/server/server-pipeline/src/pipeline.ts b/server/server-pipeline/src/pipeline.ts index bffe65d265d..d2e1a32347c 100644 --- a/server/server-pipeline/src/pipeline.ts +++ b/server/server-pipeline/src/pipeline.ts @@ -40,7 +40,8 @@ import { TriggersMiddleware, TxMiddleware, UserStatusMiddleware, - GuestPermissionsMiddleware + GuestPermissionsMiddleware, + NormalizeTxMiddleware } from '@hcengineering/middleware' import { createBenchmarkAdapter, @@ -118,6 +119,7 @@ export function createServerPipeline ( const middlewares: MiddlewareCreator[] = [ LookupMiddleware.create, + NormalizeTxMiddleware.create, IdentityMiddleware.create, ModifiedMiddleware.create, FindSecurityMiddleware.create,