diff --git a/apps/nestjs-backend/src/features/auth/auth.controller.ts b/apps/nestjs-backend/src/features/auth/auth.controller.ts index 3b53d709c..45ea6edac 100644 --- a/apps/nestjs-backend/src/features/auth/auth.controller.ts +++ b/apps/nestjs-backend/src/features/auth/auth.controller.ts @@ -55,7 +55,7 @@ export class AuthController { @Get('/user/me') async me(@Req() request: Express.Request) { - return { ...request.user!, _session_ticket: request.sessionID }; + return request.user; } @Patch('/change-password') diff --git a/apps/nestjs-backend/src/features/auth/auth.module.ts b/apps/nestjs-backend/src/features/auth/auth.module.ts index 4021acd17..43d5581e9 100644 --- a/apps/nestjs-backend/src/features/auth/auth.module.ts +++ b/apps/nestjs-backend/src/features/auth/auth.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; import { PassportModule } from '@nestjs/passport'; import { AccessTokenModule } from '../access-token/access-token.module'; import { UserModule } from '../user/user.module'; @@ -26,10 +25,6 @@ import { SessionStrategy } from './strategies/session.strategy'; AuthService, LocalStrategy, SessionStrategy, - { - provide: APP_GUARD, - useClass: AuthGuard, - }, AuthGuard, SessionSerializer, SessionStoreService, diff --git a/apps/nestjs-backend/src/features/auth/permission.module.ts b/apps/nestjs-backend/src/features/auth/permission.module.ts index f332d4f70..01343ad4f 100644 --- a/apps/nestjs-backend/src/features/auth/permission.module.ts +++ b/apps/nestjs-backend/src/features/auth/permission.module.ts @@ -1,18 +1,10 @@ import { Global, Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; import { PermissionGuard } from './guard/permission.guard'; import { PermissionService } from './permission.service'; @Global() @Module({ - providers: [ - PermissionService, - PermissionGuard, - { - provide: APP_GUARD, - useClass: PermissionGuard, - }, - ], + providers: [PermissionService, PermissionGuard], exports: [PermissionService, PermissionGuard], }) export class PermissionModule {} diff --git a/apps/nestjs-backend/src/features/field/field-permission.service.ts b/apps/nestjs-backend/src/features/field/field-permission.service.ts deleted file mode 100644 index aab5ef137..000000000 --- a/apps/nestjs-backend/src/features/field/field-permission.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import type { IGetFieldsQuery } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; -import { ClsService } from 'nestjs-cls'; -import type { IClsStore } from '../../types/cls'; -import { createViewVoByRaw } from '../view/model/factory'; - -@Injectable() -export class FieldPermissionService { - constructor( - private readonly cls: ClsService, - private readonly prismaService: PrismaService - ) {} - - async getFieldsQueryWithPermission(tableId: string, fieldsQuery?: IGetFieldsQuery) { - const shareViewId = this.cls.get('shareViewId'); - if (shareViewId) { - return this.getFieldsQueryWithSharePermission(tableId, fieldsQuery); - } - return fieldsQuery; - } - - private async getFieldsQueryWithSharePermission(tableId: string, fieldsQuery?: IGetFieldsQuery) { - const { viewId } = fieldsQuery ?? {}; - const shareViewId = this.cls.get('shareViewId'); - const view = await this.prismaService.txClient().view.findFirst({ - where: { - tableId, - shareId: shareViewId, - ...(viewId ? { id: viewId } : {}), - enableShare: true, - deletedTime: null, - }, - }); - if (!view) { - throw new BadRequestException('error shareId'); - } - const filterHidden = !createViewVoByRaw(view).shareMeta?.includeHiddenField; - return { ...fieldsQuery, viewId: view.id, filterHidden }; - } -} diff --git a/apps/nestjs-backend/src/features/field/field.service.ts b/apps/nestjs-backend/src/features/field/field.service.ts index 81d2b2847..9fab1364b 100644 --- a/apps/nestjs-backend/src/features/field/field.service.ts +++ b/apps/nestjs-backend/src/features/field/field.service.ts @@ -25,7 +25,6 @@ import type { IClsStore } from '../../types/cls'; import { isNotHiddenField } from '../../utils/is-not-hidden-field'; import { convertNameToValidCharacter } from '../../utils/name-conversion'; import { BatchService } from '../calculation/batch.service'; -import { FieldPermissionService } from './field-permission.service'; import type { IFieldInstance } from './model/factory'; import { createFieldInstanceByVo, rawField2FieldObj } from './model/factory'; import { dbType2knexFormat } from './util'; @@ -38,7 +37,6 @@ export class FieldService implements IReadonlyAdapterService { private readonly batchService: BatchService, private readonly prismaService: PrismaService, private readonly cls: ClsService, - private readonly fieldPermissionService: FieldPermissionService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -494,12 +492,7 @@ export class FieldService implements IReadonlyAdapterService { } async getDocIdsByQuery(tableId: string, query: IGetFieldsQuery) { - const fieldsQuery = await this.fieldPermissionService.getFieldsQueryWithPermission( - tableId, - query - ); - const result = await this.getFieldsByQuery(tableId, fieldsQuery); - + const result = await this.getFieldsByQuery(tableId, query); return { ids: result.map((field) => field.id), }; diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts index f035fccc8..6ad3b4f31 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts @@ -104,4 +104,19 @@ export class FieldOpenApiController { async deleteField(@Param('tableId') tableId: string, @Param('fieldId') fieldId: string) { await this.fieldOpenApiService.deleteField(tableId, fieldId); } + + @Permissions('field|read') + @Get('/socket/snapshot-bulk') + async getSnapshotBulk(@Param('tableId') tableId: string, @Query('ids') ids: string[]) { + return this.fieldService.getSnapshotBulk(tableId, ids); + } + + @Permissions('field|read') + @Get('/socket/doc-ids') + async getDocIds( + @Param('tableId') tableId: string, + @Query(new ZodValidationPipe(getFieldsQuerySchema)) query: IGetFieldsQuery + ) { + return this.fieldService.getDocIdsByQuery(tableId, query); + } } diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts index bb5979b23..cbd1b78fa 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts @@ -81,4 +81,23 @@ export class RecordOpenApiController { ): Promise { return await this.recordOpenApiService.deleteRecords(tableId, query.recordIds); } + + @Permissions('record|read') + @Get('/socket/snapshot-bulk') + async getSnapshotBulk( + @Param('tableId') tableId: string, + @Query('ids') ids: string[], + @Query('projection') projection?: { [fieldNameOrId: string]: boolean } + ) { + return this.recordService.getSnapshotBulk(tableId, ids, projection); + } + + @Permissions('record|read') + @Get('/socket/doc-ids') + async getDocIds( + @Param('tableId') tableId: string, + @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo + ) { + return this.recordService.getDocIdsByQuery(tableId, query); + } } diff --git a/apps/nestjs-backend/src/features/record/record-permission.service.ts b/apps/nestjs-backend/src/features/record/record-permission.service.ts deleted file mode 100644 index 282b23cd8..000000000 --- a/apps/nestjs-backend/src/features/record/record-permission.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import type { FieldKeyType } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; -import type { IGetRecordsRo } from '@teable/openapi'; -import { ClsService } from 'nestjs-cls'; -import type { IClsStore } from '../../types/cls'; -import { isNotHiddenField } from '../../utils/is-not-hidden-field'; -import { createViewVoByRaw } from '../view/model/factory'; - -@Injectable() -export class RecordPermissionService { - constructor( - private readonly cls: ClsService, - private readonly prismaService: PrismaService - ) {} - - async getRecordQueryWithPermission(tableId: string, recordQuery: IGetRecordsRo) { - const shareViewId = this.cls.get('shareViewId'); - if (shareViewId) { - return this.getRecordQueryWithSharePermission(tableId, recordQuery); - } - return recordQuery; - } - - protected async getRecordQueryWithSharePermission(tableId: string, recordQuery: IGetRecordsRo) { - const { viewId } = recordQuery; - const viewIdWithShare = await this.getViewIdByShare(tableId, viewId); - return { - ...recordQuery, - viewId: viewIdWithShare, - }; - } - - protected async getViewIdByShare(tableId: string, viewId?: string) { - const shareId = this.cls.get('shareViewId'); - if (!shareId) { - return viewId; - } - const view = await this.prismaService.txClient().view.findFirst({ - select: { id: true }, - where: { - tableId, - shareId, - ...(viewId ? { id: viewId } : {}), - enableShare: true, - deletedTime: null, - }, - }); - if (!view) { - throw new BadRequestException('error shareId'); - } - return view.id; - } - - async getProjectionWithPermission( - tableId: string, - fieldKeyType: FieldKeyType, - projection?: { [fieldNameOrId: string]: boolean } - ) { - const shareViewId = this.cls.get('shareViewId'); - if (shareViewId) { - return this.getProjectionWithSharePermission(tableId, fieldKeyType, projection); - } - return projection; - } - - protected async getProjectionWithSharePermission( - tableId: string, - fieldKeyType: FieldKeyType, - projection?: { [fieldNameOrId: string]: boolean } - ) { - const shareId = this.cls.get('shareViewId'); - const projectionInner = projection || {}; - if (shareId) { - const rawView = await this.prismaService.txClient().view.findFirst({ - where: { shareId: shareId, enableShare: true, deletedTime: null }, - }); - - if (!rawView) { - throw new NotFoundException('error shareId'); - } - - const view = createViewVoByRaw(rawView); - - const fields = await this.prismaService.txClient().field.findMany({ - where: { tableId, deletedTime: null }, - select: { - id: true, - name: true, - }, - }); - - if (!view?.shareMeta?.includeHiddenField) { - fields - .filter((field) => isNotHiddenField(field.id, view)) - .forEach((field) => (projectionInner[field[fieldKeyType]] = true)); - } - } - return Object.keys(projectionInner).length ? projectionInner : undefined; - } - - async hasUpdateRecordPermission(_tableId: string, _recordId: string) { - const shareViewId = this.cls.get('shareViewId'); - if (shareViewId) { - return false; - } - return true; - } - - async hasUpdateRecordPermissionOrThrow(tableId: string, recordId: string) { - if (!(await this.hasUpdateRecordPermission(tableId, recordId))) { - throw new ForbiddenException(`no has update ${recordId} permission`); - } - } - - async getDeniedReadRecordsPermission(_tableId: string, _recordIds: string[]): Promise { - return []; - } -} diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index af49743a2..0efcd3f57 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -12,7 +12,6 @@ import type { IGroup, ILinkCellValue, IRecord, - ISetRecordOpContext, ISnapshotBase, ISortItem, } from '@teable/core'; @@ -25,7 +24,6 @@ import { IdPrefix, mergeWithDefaultFilter, mergeWithDefaultSort, - OpName, parseGroup, Relationship, } from '@teable/core'; @@ -34,13 +32,12 @@ import { PrismaService } from '@teable/db-main-prisma'; import type { ICreateRecordsRo, IGetRecordQuery, IGetRecordsRo, IRecordsVo } from '@teable/openapi'; import { UploadType } from '@teable/openapi'; import { Knex } from 'knex'; -import { keyBy } from 'lodash'; +import { difference, keyBy } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; -import type { IAdapterService } from '../../share-db/interface'; import { RawOpType } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; import { Timing } from '../../utils/timing'; @@ -52,12 +49,11 @@ import { preservedDbFieldNames } from '../field/constant'; import type { IFieldInstance } from '../field/model/factory'; import { createFieldInstanceByRaw } from '../field/model/factory'; import { ROW_ORDER_FIELD_PREFIX } from '../view/constant'; -import { RecordPermissionService } from './record-permission.service'; type IUserFields = { id: string; dbFieldName: string }[]; @Injectable() -export class RecordService implements IAdapterService { +export class RecordService { private logger = new Logger(RecordService.name); constructor( @@ -65,7 +61,6 @@ export class RecordService implements IAdapterService { private readonly batchService: BatchService, private readonly attachmentStorageService: AttachmentsStorageService, private readonly cls: ClsService, - private readonly recordPermissionService: RecordPermissionService, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, @InjectDbProvider() private readonly dbProvider: IDbProvider, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig @@ -768,25 +763,6 @@ export class RecordService implements IAdapterService { await this.batchDel(tableId, [recordId]); } - async update( - version: number, - tableId: string, - recordId: string, - opContexts: ISetRecordOpContext[] - ) { - await this.recordPermissionService.hasUpdateRecordPermissionOrThrow(tableId, recordId); - const dbTableName = await this.getDbTableName(tableId); - if (opContexts[0].name === OpName.SetRecord) { - await this.setRecord( - version, - tableId, - dbTableName, - recordId, - opContexts as ISetRecordOpContext[] - ); - } - } - private async getFieldsByProjection( tableId: string, projection?: { [fieldNameOrId: string]: boolean }, @@ -857,20 +833,9 @@ export class RecordService implements IAdapterService { fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default. cellFormat = CellFormat.Json ): Promise[]> { - const projectionInner = await this.recordPermissionService.getProjectionWithPermission( - tableId, - fieldKeyType, - projection - ); - - const deniedRecordIds = await this.recordPermissionService.getDeniedReadRecordsPermission( - tableId, - recordIds - ); - const dbTableName = await this.getDbTableName(tableId); - const fields = await this.getFieldsByProjection(tableId, projectionInner, fieldKeyType); + const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType); const fieldNames = fields.map((f) => f.dbFieldName).concat(Array.from(preservedDbFieldNames)); const nativeQuery = this.knex(dbTableName) .select(fieldNames) @@ -907,29 +872,6 @@ export class RecordService implements IAdapterService { return recordIdsMap[a.__id] - recordIdsMap[b.__id]; }) .map((record) => { - if (deniedRecordIds.includes(record.__id)) { - const recordPrimaryFields = this.dbRecord2RecordFields( - record, - [primaryField], - fieldKeyType, - cellFormat - ); - const primaryFieldName = recordPrimaryFields[primaryField[fieldKeyType]]; - return { - id: record.__id, - v: record.__version, - type: 'json0', - data: { - fields: recordPrimaryFields, - isDenied: true, - name: - cellFormat === CellFormat.Text - ? (primaryFieldName as string) - : primaryField.cellValue2String(primaryFieldName), - id: record.__id, - }, - }; - } const recordFields = this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat); const name = recordFields[primaryField[fieldKeyType]]; return { @@ -961,12 +903,8 @@ export class RecordService implements IAdapterService { tableId: string, query: IGetRecordsRo ): Promise<{ ids: string[]; extra?: IExtraResult }> { - const recordQuery = await this.recordPermissionService.getRecordQueryWithPermission( - tableId, - query - ); + const { skip, take = 100 } = query; - const { skip, take = 100 } = recordQuery; if (identify(tableId) !== IdPrefix.Table) { throw new InternalServerErrorException('query collection must be table id'); } @@ -975,7 +913,7 @@ export class RecordService implements IAdapterService { throw new BadRequestException(`limit can't be greater than ${take}`); } - const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(tableId, recordQuery); + const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(tableId, query); queryBuilder.select(this.knex.ref(`${dbTableName}.__id`)); @@ -1000,8 +938,6 @@ export class RecordService implements IAdapterService { throw new InternalServerErrorException('query collection must be table id'); } - query = await this.recordPermissionService.getRecordQueryWithPermission(tableId, query); - const { skip, take, @@ -1070,4 +1006,18 @@ export class RecordService implements IAdapterService { return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql); } + + async getDiffIdsByIdAndFilter(tableId: string, recordIds: string[], filter?: IFilter | null) { + const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(tableId, { + filter, + }); + + queryBuilder.select(this.knex.ref(`${dbTableName}.__id`)); + + const result = await this.prismaService + .txClient() + .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery()); + const ids = result.map((r) => r.__id); + return difference(recordIds, ids); + } } diff --git a/apps/nestjs-backend/src/features/share/guard/auth.guard.ts b/apps/nestjs-backend/src/features/share/guard/auth.guard.ts index 92a8d52fd..29b96b360 100644 --- a/apps/nestjs-backend/src/features/share/guard/auth.guard.ts +++ b/apps/nestjs-backend/src/features/share/guard/auth.guard.ts @@ -1,8 +1,9 @@ import type { ExecutionContext } from '@nestjs/common'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; -import { ANONYMOUS_USER_ID } from '@teable/core'; +import { ANONYMOUS_USER_ID, HttpErrorCode } from '@teable/core'; import { ClsService } from 'nestjs-cls'; +import { CustomHttpException } from '../../../custom.exception'; import type { IClsStore } from '../../../types/cls'; import { ShareAuthService } from '../share-auth.service'; import { SHARE_JWT_STRATEGY } from './constant'; @@ -33,7 +34,7 @@ export class AuthGuard extends PassportAuthGuard([SHARE_JWT_STRATEGY]) { } return true; } catch (err) { - throw new UnauthorizedException(); + throw new CustomHttpException('Unauthorized', HttpErrorCode.UNAUTHORIZED_SHARE); } } diff --git a/apps/nestjs-backend/src/features/share/share-socket.service.ts b/apps/nestjs-backend/src/features/share/share-socket.service.ts new file mode 100644 index 000000000..44cc8138c --- /dev/null +++ b/apps/nestjs-backend/src/features/share/share-socket.service.ts @@ -0,0 +1,67 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import type { IGetFieldsQuery } from '@teable/core'; +import type { IGetRecordsRo } from '@teable/openapi'; +import { Knex } from 'knex'; +import { difference } from 'lodash'; +import { InjectModel } from 'nest-knexjs'; +import { FieldService } from '../field/field.service'; +import { RecordService } from '../record/record.service'; +import { ViewService } from '../view/view.service'; +import type { IShareViewInfo } from './share-auth.service'; + +@Injectable() +export class ShareSocketService { + constructor( + private readonly viewService: ViewService, + private readonly fieldService: FieldService, + private readonly recordService: RecordService, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex + ) {} + + getViewDocIdsByQuery(shareInfo: IShareViewInfo) { + const { tableId, view } = shareInfo; + return this.viewService.getDocIdsByQuery(tableId, { + includeIds: [view.id], + }); + } + + getViewSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { + const { tableId, view } = shareInfo; + if (ids.length > 1 || ids[0] !== view.id) { + throw new ForbiddenException('View permission not allowed: read'); + } + return this.viewService.getSnapshotBulk(tableId, [view.id]); + } + + getFieldDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetFieldsQuery = {}) { + const { tableId, view } = shareInfo; + const filterHidden = !view.shareMeta?.includeHiddenField; + return this.fieldService.getDocIdsByQuery(tableId, { ...query, viewId: view.id, filterHidden }); + } + + async getFieldSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { + const { tableId } = shareInfo; + const { ids: fieldIds } = await this.getFieldDocIdsByQuery(shareInfo); + const unPermissionIds = difference(ids, fieldIds); + if (unPermissionIds.length) { + throw new ForbiddenException( + `Field(${unPermissionIds.join(',')}) permission not allowed: read` + ); + } + return this.fieldService.getSnapshotBulk(tableId, ids); + } + + getRecordDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetRecordsRo) { + const { tableId, view } = shareInfo; + return this.recordService.getDocIdsByQuery(tableId, { ...query, viewId: view.id }); + } + + async getRecordSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { + const { tableId, view } = shareInfo; + const diff = await this.recordService.getDiffIdsByIdAndFilter(tableId, ids, view.filter); + if (diff.length) { + throw new ForbiddenException(`Record(${diff.join(',')}) permission not allowed: read`); + } + return this.recordService.getSnapshotBulk(tableId, ids); + } +} diff --git a/apps/nestjs-backend/src/features/share/share.controller.ts b/apps/nestjs-backend/src/features/share/share.controller.ts index 73b0361c7..44c965485 100644 --- a/apps/nestjs-backend/src/features/share/share.controller.ts +++ b/apps/nestjs-backend/src/features/share/share.controller.ts @@ -11,6 +11,7 @@ import { Body, Query, } from '@nestjs/common'; +import { IGetFieldsQuery, getFieldsQuerySchema } from '@teable/core'; import { ShareViewFormSubmitRo, shareViewFormSubmitRoSchema, @@ -26,6 +27,8 @@ import { IShareViewLinkRecordsRo, shareViewCollaboratorsRoSchema, IShareViewCollaboratorsRo, + getRecordsRoSchema, + IGetRecordsRo, } from '@teable/openapi'; import type { IRecord, @@ -44,6 +47,7 @@ import { TqlPipe } from '../record/open-api/tql.pipe'; import { AuthGuard } from './guard/auth.guard'; import { ShareAuthLocalGuard } from './guard/share-auth-local.guard'; import { ShareAuthService } from './share-auth.service'; +import { ShareSocketService } from './share-socket.service'; import type { IShareViewInfo } from './share.service'; import { ShareService } from './share.service'; @@ -52,7 +56,8 @@ import { ShareService } from './share.service'; export class ShareController { constructor( private readonly shareService: ShareService, - private readonly shareAuthService: ShareAuthService + private readonly shareAuthService: ShareAuthService, + private readonly shareSocketService: ShareSocketService ) {} @HttpCode(200) @@ -149,4 +154,53 @@ export class ShareController { const shareInfo = req.shareInfo as IShareViewInfo; return this.shareService.getViewCollaborators(shareInfo, query); } + + @UseGuards(AuthGuard) + @Get('/:shareId/socket/view/snapshot-bulk') + async getViewSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) { + const shareInfo = req.shareInfo as IShareViewInfo; + return this.shareSocketService.getViewSnapshotBulk(shareInfo, ids); + } + + @UseGuards(AuthGuard) + @Get('/:shareId/socket/view/doc-ids') + async getViewDocIds(@Request() req: any) { + const shareInfo = req.shareInfo as IShareViewInfo; + return this.shareSocketService.getViewDocIdsByQuery(shareInfo); + } + + @UseGuards(AuthGuard) + @Get('/:shareId/socket/field/snapshot-bulk') + async getFieldSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) { + const shareInfo = req.shareInfo as IShareViewInfo; + return this.shareSocketService.getFieldSnapshotBulk(shareInfo, ids); + } + + @UseGuards(AuthGuard) + @Get('/:shareId/socket/field/doc-ids') + async getFieldDocIds( + @Request() req: any, + @Query(new ZodValidationPipe(getFieldsQuerySchema)) query: IGetFieldsQuery + ) { + const shareInfo = req.shareInfo as IShareViewInfo; + + return this.shareSocketService.getFieldDocIdsByQuery(shareInfo, query); + } + + @UseGuards(AuthGuard) + @Get('/:shareId/socket/record/snapshot-bulk') + async getRecordSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) { + const shareInfo = req.shareInfo as IShareViewInfo; + return this.shareSocketService.getRecordSnapshotBulk(shareInfo, ids); + } + + @UseGuards(AuthGuard) + @Get('/:shareId/socket/record/doc-ids') + async getRecordDocIds( + @Request() req: any, + @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo + ) { + const shareInfo = req.shareInfo as IShareViewInfo; + return this.shareSocketService.getRecordDocIdsByQuery(shareInfo, query); + } } diff --git a/apps/nestjs-backend/src/features/share/share.module.ts b/apps/nestjs-backend/src/features/share/share.module.ts index a62be7ef1..fe7a0e121 100644 --- a/apps/nestjs-backend/src/features/share/share.module.ts +++ b/apps/nestjs-backend/src/features/share/share.module.ts @@ -6,7 +6,9 @@ import { FieldModule } from '../field/field.module'; import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; import { RecordModule } from '../record/record.module'; import { SelectionModule } from '../selection/selection.module'; +import { ViewModule } from '../view/view.module'; import { ShareAuthModule } from './share-auth.module'; +import { ShareSocketService } from './share-socket.service'; import { ShareController } from './share.controller'; import { ShareService } from './share.service'; @@ -19,8 +21,9 @@ import { ShareService } from './share.service'; AggregationModule, ShareAuthModule, CollaboratorModule, + ViewModule, ], - providers: [ShareService, DbProvider], + providers: [ShareService, DbProvider, ShareSocketService], controllers: [ShareController], exports: [ShareService], }) diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts index 0f9c50913..74358f75f 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts @@ -23,6 +23,7 @@ import { } from '@teable/openapi'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; +import { TablePermissionService } from '../table-permission.service'; import { TableService } from '../table.service'; import { TableOpenApiService } from './table-open-api.service'; import { TablePipe } from './table.pipe'; @@ -31,7 +32,8 @@ import { TablePipe } from './table.pipe'; export class TableController { constructor( private readonly tableService: TableService, - private readonly tableOpenApiService: TableOpenApiService + private readonly tableOpenApiService: TableOpenApiService, + private readonly tablePermissionService: TablePermissionService ) {} @Permissions('table|read') @@ -158,4 +160,26 @@ export class TableController { async getPermission(@Param('baseId') baseId: string, @Param('tableId') tableId: string) { return await this.tableOpenApiService.getPermission(baseId, tableId); } + + @Permissions('table|read') + @Get('/socket/snapshot-bulk') + async getSnapshotBulk(@Param('baseId') baseId: string, @Query('ids') ids: string[]) { + const permissionMap = await this.tablePermissionService.getTablePermissionMapByBaseId( + baseId, + ids + ); + const snapshotBulk = await this.tableService.getSnapshotBulk(baseId, ids); + return snapshotBulk.map((snapshot) => { + return { + ...snapshot, + permission: permissionMap[snapshot.id], + }; + }); + } + + @Permissions('table|read') + @Get('/socket/doc-ids') + async getDocIds(@Param('baseId') baseId: string) { + return this.tableService.getDocIdsByQuery(baseId, undefined); + } } diff --git a/apps/nestjs-backend/src/features/table/table.module.ts b/apps/nestjs-backend/src/features/table/table.module.ts index 3b5cd5cb7..e5a5a64cd 100644 --- a/apps/nestjs-backend/src/features/table/table.module.ts +++ b/apps/nestjs-backend/src/features/table/table.module.ts @@ -4,11 +4,12 @@ import { CalculationModule } from '../calculation/calculation.module'; import { FieldModule } from '../field/field.module'; import { RecordModule } from '../record/record.module'; import { ViewModule } from '../view/view.module'; +import { TablePermissionService } from './table-permission.service'; import { TableService } from './table.service'; @Module({ imports: [CalculationModule, FieldModule, RecordModule, ViewModule], - providers: [TableService, DbProvider], - exports: [FieldModule, RecordModule, ViewModule, TableService], + providers: [TableService, DbProvider, TablePermissionService], + exports: [FieldModule, RecordModule, ViewModule, TableService, TablePermissionService], }) export class TableModule {} diff --git a/apps/nestjs-backend/src/features/table/table.service.ts b/apps/nestjs-backend/src/features/table/table.service.ts index 487d2ec93..03f37bb47 100644 --- a/apps/nestjs-backend/src/features/table/table.service.ts +++ b/apps/nestjs-backend/src/features/table/table.service.ts @@ -25,7 +25,6 @@ import { BatchService } from '../calculation/batch.service'; import { FieldService } from '../field/field.service'; import { RecordService } from '../record/record.service'; import { ViewService } from '../view/view.service'; -import { TablePermissionService } from './table-permission.service'; @Injectable() export class TableService implements IReadonlyAdapterService { @@ -38,7 +37,6 @@ export class TableService implements IReadonlyAdapterService { private readonly viewService: ViewService, private readonly fieldService: FieldService, private readonly recordService: RecordService, - private readonly tablePermissionService: TablePermissionService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -356,10 +354,6 @@ export class TableService implements IReadonlyAdapterService { where: { baseId, id: { in: ids }, deletedTime: null }, orderBy: { order: 'asc' }, }); - const tablePermissionMap = await this.tablePermissionService.getTablePermissionMapByBaseId( - baseId, - ids - ); const tableTime = await this.getTableLastModifiedTime(ids); const tableDefaultViewIds = await this.getTableDefaultViewId(ids); return tables @@ -375,14 +369,13 @@ export class TableService implements IReadonlyAdapterService { icon: table.icon ?? undefined, lastModifiedTime: tableTime[i] || table.createdTime.toISOString(), defaultViewId: tableDefaultViewIds[i], - permission: tablePermissionMap[table.id], }, }; }); } - async getDocIdsByQuery(baseId: string, _query: unknown) { - const projectionTableIds = await this.tablePermissionService.getProjectionTableIds(baseId); + async getDocIdsByQuery(baseId: string, query: { projectionTableIds?: string[] } = {}) { + const { projectionTableIds } = query; const tables = await this.prismaService.txClient().tableMeta.findMany({ where: { deletedTime: null, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts index f19c88860..38c313d88 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import type { IViewVo } from '@teable/core'; import { viewRoSchema, @@ -261,4 +261,16 @@ export class ViewOpenApiController { ): Promise { return this.viewOpenApiService.getFilterLinkRecords(tableId, viewId); } + + @Permissions('view|read') + @Get('/socket/snapshot-bulk') + async getSnapshotBulk(@Param('tableId') tableId: string, @Query('ids') ids: string[]) { + return this.viewService.getSnapshotBulk(tableId, ids); + } + + @Permissions('view|read') + @Get('/socket/doc-ids') + async getDocIds(@Param('tableId') tableId: string) { + return this.viewService.getDocIdsByQuery(tableId, undefined); + } } diff --git a/apps/nestjs-backend/src/features/view/view-permission.service.ts b/apps/nestjs-backend/src/features/view/view-permission.service.ts deleted file mode 100644 index 029134fc5..000000000 --- a/apps/nestjs-backend/src/features/view/view-permission.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ClsService } from 'nestjs-cls'; -import type { IClsStore } from '../../types/cls'; - -@Injectable() -export class ViewPermissionService { - constructor(private readonly cls: ClsService) {} - - async getViewQueryWithPermission() { - const shareViewId = this.cls.get('shareViewId'); - if (shareViewId) { - return this.getViewQueryWithSharePermission(); - } - return {}; - } - - private async getViewQueryWithSharePermission() { - const shareViewId = this.cls.get('shareViewId'); - return { shareId: shareViewId, enableShare: true }; - } -} diff --git a/apps/nestjs-backend/src/features/view/view.service.ts b/apps/nestjs-backend/src/features/view/view.service.ts index 7c53f56d2..fe13392f8 100644 --- a/apps/nestjs-backend/src/features/view/view.service.ts +++ b/apps/nestjs-backend/src/features/view/view.service.ts @@ -34,7 +34,6 @@ import type { IClsStore } from '../../types/cls'; import { BatchService } from '../calculation/batch.service'; import { ROW_ORDER_FIELD_PREFIX } from './constant'; import { createViewInstanceByRaw, createViewVoByRaw } from './model/factory'; -import { ViewPermissionService } from './view-permission.service'; type IViewOpContext = IUpdateViewColumnMetaOpContext | ISetViewPropertyOpContext; @@ -44,7 +43,6 @@ export class ViewService implements IReadonlyAdapterService { private readonly cls: ClsService, private readonly batchService: BatchService, private readonly prismaService: PrismaService, - private readonly viewPermissionService: ViewPermissionService, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, @InjectDbProvider() private readonly dbProvider: IDbProvider ) {} @@ -391,10 +389,8 @@ export class ViewService implements IReadonlyAdapterService { } async getSnapshotBulk(tableId: string, ids: string[]): Promise[]> { - const viewQuery = await this.viewPermissionService.getViewQueryWithPermission(); - const views = await this.prismaService.txClient().view.findMany({ - where: { tableId, id: { in: ids }, ...viewQuery }, + where: { tableId, id: { in: ids } }, }); return views @@ -409,11 +405,9 @@ export class ViewService implements IReadonlyAdapterService { .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); } - async getDocIdsByQuery(tableId: string, _query: unknown) { - const viewQuery = await this.viewPermissionService.getViewQueryWithPermission(); - + async getDocIdsByQuery(tableId: string, query?: { includeIds: string[] }) { const views = await this.prismaService.txClient().view.findMany({ - where: { tableId, deletedTime: null, ...viewQuery }, + where: { tableId, deletedTime: null, id: { in: query?.includeIds } }, select: { id: true }, orderBy: { order: 'asc' }, }); diff --git a/apps/nestjs-backend/src/global/global.module.ts b/apps/nestjs-backend/src/global/global.module.ts index 509f2daad..b2802c3a3 100644 --- a/apps/nestjs-backend/src/global/global.module.ts +++ b/apps/nestjs-backend/src/global/global.module.ts @@ -1,5 +1,6 @@ import type { DynamicModule, MiddlewareConsumer, ModuleMetadata, NestModule } from '@nestjs/common'; import { Global, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { context, trace } from '@opentelemetry/api'; import { PrismaModule } from '@teable/db-main-prisma'; import type { Request } from 'express'; @@ -10,12 +11,10 @@ import { ConfigModule } from '../configs/config.module'; import { X_REQUEST_ID } from '../const'; import { DbProvider } from '../db-provider/db.provider'; import { EventEmitterModule } from '../event-emitter/event-emitter.module'; +import { AuthGuard } from '../features/auth/guard/auth.guard'; +import { PermissionGuard } from '../features/auth/guard/permission.guard'; import { PermissionModule } from '../features/auth/permission.module'; -import { FieldPermissionService } from '../features/field/field-permission.service'; import { MailSenderModule } from '../features/mail-sender/mail-sender.module'; -import { RecordPermissionService } from '../features/record/record-permission.service'; -import { TablePermissionService } from '../features/table/table-permission.service'; -import { ViewPermissionService } from '../features/view/view-permission.service'; import { KnexModule } from './knex'; const globalModules = { @@ -48,18 +47,16 @@ const globalModules = { // for overriding the default TablePermissionService, FieldPermissionService, RecordPermissionService, and ViewPermissionService providers: [ DbProvider, - FieldPermissionService, - RecordPermissionService, - ViewPermissionService, - TablePermissionService, - ], - exports: [ - DbProvider, - FieldPermissionService, - RecordPermissionService, - ViewPermissionService, - TablePermissionService, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + { + provide: APP_GUARD, + useClass: PermissionGuard, + }, ], + exports: [DbProvider], }; @Global() diff --git a/apps/nestjs-backend/src/share-db/auth.middleware.ts b/apps/nestjs-backend/src/share-db/auth.middleware.ts index df0e0a77e..6e083f95f 100644 --- a/apps/nestjs-backend/src/share-db/auth.middleware.ts +++ b/apps/nestjs-backend/src/share-db/auth.middleware.ts @@ -1,32 +1,32 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import url from 'url'; import type ShareDBClass from 'sharedb'; -import type { ShareDbPermissionService } from './share-db-permission.service'; -export const authMiddleware = ( - shareDB: ShareDBClass, - shareDbPermissionService: ShareDbPermissionService -) => { +export const authMiddleware = (shareDB: ShareDBClass) => { + const runWithCls = async (context: ShareDBClass.middleware.QueryContext, callback: any) => { + const cookie = context.agent.custom.cookie; + const shareId = context.agent.custom.shareId; + if (context.options) { + context.options = { ...context.options, cookie, shareId }; + } else { + context.options = { cookie, shareId }; + } + callback(); + }; + shareDB.use('connect', async (context, callback) => { if (!context.req) { - context.agent.custom.isBackend = true; callback(); return; } const cookie = context.req.headers.cookie; context.agent.custom.cookie = cookie; - context.agent.custom.sessionId = context.req.sessionID; const newUrl = new url.URL(context.req.url, 'https://example.com'); - context.agent.custom.shareId = newUrl.searchParams.get('shareId'); - await shareDbPermissionService.authMiddleware(context, callback); + const shareId = newUrl.searchParams.get('shareId'); + context.agent.custom.shareId = shareId; + callback(); }); - shareDB.use('apply', (context, callback) => - shareDbPermissionService.authMiddleware(context, callback) - ); - - shareDB.use('query', (context, callback) => - shareDbPermissionService.authMiddleware(context, callback) - ); + shareDB.use('query', (context, callback) => runWithCls(context, callback)); }; diff --git a/apps/nestjs-backend/src/share-db/derivate.middleware.ts b/apps/nestjs-backend/src/share-db/derivate.middleware.ts deleted file mode 100644 index c0cb6c80b..000000000 --- a/apps/nestjs-backend/src/share-db/derivate.middleware.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ClsService } from 'nestjs-cls'; -import type ShareDBClass from 'sharedb'; -import type { IClsStore } from '../types/cls'; -import type { ShareDbService } from './share-db.service'; -import type { ICustomSubmitContext, WsDerivateService } from './ws-derivate.service'; - -export const derivateMiddleware = ( - shareDB: ShareDbService, - cls: ClsService, - wsDerivateService: WsDerivateService -) => { - shareDB.use( - 'apply', - async (context: ShareDBClass.middleware.ApplyContext, next: (err?: unknown) => void) => { - await wsDerivateService.onRecordApply(context, next); - } - ); - - shareDB.use( - 'afterWrite', - async (context: ICustomSubmitContext, next: (err?: unknown) => void) => { - // console.log('afterWrite:context', JSON.stringify(context.extra, null, 2)); - const saveContext = context.extra.saveContext; - const stashOpMap = context.extra.stashOpMap; - - if (saveContext) { - stashOpMap && cls.set('tx.stashOpMap', stashOpMap); - - try { - await wsDerivateService.save(saveContext); - } catch (e) { - // TODO: rollback - return next(e); - } - } - - next(); - } - ); -}; diff --git a/apps/nestjs-backend/src/share-db/readonly/field-readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/field-readonly.service.ts new file mode 100644 index 000000000..82bae8f11 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/readonly/field-readonly.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import type { IGetFieldsQuery } from '@teable/core'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import type { IReadonlyAdapterService } from '../interface'; +import { ReadonlyService } from './readonly.service'; + +@Injectable() +export class FieldReadonlyServiceAdapter + extends ReadonlyService + implements IReadonlyAdapterService +{ + constructor(private readonly cls: ClsService) { + super(cls); + } + + getDocIdsByQuery(tableId: string, query: IGetFieldsQuery = {}) { + const shareId = this.cls.get('shareViewId'); + const url = shareId + ? `/share/${shareId}/socket/field/doc-ids` + : `/table/${tableId}/field/socket/doc-ids`; + return this.axios + .get(url, { + headers: { + cookie: this.cls.get('cookie'), + }, + params: query, + }) + .then((res) => res.data); + } + getSnapshotBulk(tableId: string, ids: string[]) { + const shareId = this.cls.get('shareViewId'); + const url = shareId + ? `/share/${shareId}/socket/field/snapshot-bulk` + : `/table/${tableId}/field/socket/snapshot-bulk`; + return this.axios + .get(url, { + headers: { + cookie: this.cls.get('cookie'), + }, + params: { + ids, + }, + }) + .then((res) => res.data); + } +} diff --git a/apps/nestjs-backend/src/share-db/readonly/readonly.module.ts b/apps/nestjs-backend/src/share-db/readonly/readonly.module.ts new file mode 100644 index 000000000..057aece27 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/readonly/readonly.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { FieldReadonlyServiceAdapter } from './field-readonly.service'; +import { RecordReadonlyServiceAdapter } from './record-readonly.service'; +import { TableReadonlyServiceAdapter } from './table-readonly.service'; +import { ViewReadonlyServiceAdapter } from './view-readonly.service'; + +@Module({ + imports: [], + providers: [ + RecordReadonlyServiceAdapter, + FieldReadonlyServiceAdapter, + ViewReadonlyServiceAdapter, + TableReadonlyServiceAdapter, + ], + exports: [ + RecordReadonlyServiceAdapter, + FieldReadonlyServiceAdapter, + ViewReadonlyServiceAdapter, + TableReadonlyServiceAdapter, + ], +}) +export class ReadonlyModule {} diff --git a/apps/nestjs-backend/src/share-db/readonly/readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/readonly.service.ts new file mode 100644 index 000000000..d67da1186 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/readonly/readonly.service.ts @@ -0,0 +1,15 @@ +import { createAxios } from '@teable/openapi'; +import type { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; + +export class ReadonlyService { + protected axios; + constructor(clsService: ClsService) { + this.axios = createAxios(); + this.axios.interceptors.request.use((config) => { + config.headers.cookie = clsService.get('cookie'); + config.baseURL = `http://localhost:${process.env.PORT}/api`; + return config; + }); + } +} diff --git a/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts new file mode 100644 index 000000000..14bbea2c5 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import type { IGetRecordsRo } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import type { IReadonlyAdapterService } from '../interface'; +import { ReadonlyService } from './readonly.service'; + +@Injectable() +export class RecordReadonlyServiceAdapter + extends ReadonlyService + implements IReadonlyAdapterService +{ + constructor(private readonly cls: ClsService) { + super(cls); + } + + getDocIdsByQuery(tableId: string, query: IGetRecordsRo = {}) { + const shareId = this.cls.get('shareViewId'); + const url = shareId + ? `/share/${shareId}/socket/record/doc-ids` + : `/table/${tableId}/record/socket/doc-ids`; + return this.axios + .get(url, { + headers: { + cookie: this.cls.get('cookie'), + }, + params: { + ...query, + filter: JSON.stringify(query?.filter), + orderBy: JSON.stringify(query?.orderBy), + groupBy: JSON.stringify(query?.groupBy), + }, + }) + .then((res) => res.data); + } + getSnapshotBulk( + tableId: string, + recordIds: string[], + projection?: { [fieldNameOrId: string]: boolean } + ) { + const shareId = this.cls.get('shareViewId'); + const url = shareId + ? `/share/${shareId}/socket/record/snapshot-bulk` + : `/table/${tableId}/record/socket/snapshot-bulk`; + return this.axios + .get(url, { + headers: { + cookie: this.cls.get('cookie'), + }, + params: { + ids: recordIds, + projection, + }, + }) + .then((res) => res.data); + } +} diff --git a/apps/nestjs-backend/src/share-db/readonly/table-readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/table-readonly.service.ts new file mode 100644 index 000000000..d8747885b --- /dev/null +++ b/apps/nestjs-backend/src/share-db/readonly/table-readonly.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import type { IReadonlyAdapterService } from '../interface'; +import { ReadonlyService } from './readonly.service'; + +@Injectable() +export class TableReadonlyServiceAdapter + extends ReadonlyService + implements IReadonlyAdapterService +{ + constructor(private readonly cls: ClsService) { + super(cls); + } + + getDocIdsByQuery(baseId: string) { + return this.axios + .get(`/base/${baseId}/table/socket/doc-ids`, { + headers: { + cookie: this.cls.get('cookie'), + }, + }) + .then((res) => res.data); + } + getSnapshotBulk(baseId: string, ids: string[]) { + return this.axios + .get(`/base/${baseId}/table/socket/snapshot-bulk`, { + headers: { + cookie: this.cls.get('cookie'), + }, + params: { + ids, + }, + }) + .then((res) => res.data); + } +} diff --git a/apps/nestjs-backend/src/share-db/readonly/view-readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/view-readonly.service.ts new file mode 100644 index 000000000..291d2ce33 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/readonly/view-readonly.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import type { IReadonlyAdapterService } from '../interface'; +import { ReadonlyService } from './readonly.service'; + +@Injectable() +export class ViewReadonlyServiceAdapter extends ReadonlyService implements IReadonlyAdapterService { + constructor(private readonly cls: ClsService) { + super(cls); + } + + getDocIdsByQuery(tableId: string) { + const shareId = this.cls.get('shareViewId'); + const url = shareId + ? `/share/${shareId}/socket/view/doc-ids` + : `/table/${tableId}/view/socket/doc-ids`; + return this.axios + .get(url, { + headers: { + cookie: this.cls.get('cookie'), + }, + }) + .then((res) => res.data); + } + getSnapshotBulk(tableId: string, ids: string[]) { + const shareId = this.cls.get('shareViewId'); + const url = shareId + ? `/share/${shareId}/socket/view/snapshot-bulk` + : `/table/${tableId}/view/socket/snapshot-bulk`; + return this.axios + .get(url, { + headers: { + cookie: this.cls.get('cookie'), + }, + params: { + ids, + }, + }) + .then((res) => res.data); + } +} diff --git a/apps/nestjs-backend/src/share-db/share-db-permission.service.spec.ts b/apps/nestjs-backend/src/share-db/share-db-permission.service.spec.ts deleted file mode 100644 index f746e05f3..000000000 --- a/apps/nestjs-backend/src/share-db/share-db-permission.service.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { TestingModule } from '@nestjs/testing'; -import { Test } from '@nestjs/testing'; -import { ClsService } from 'nestjs-cls'; -import { vi } from 'vitest'; -import { mockDeep } from 'vitest-mock-extended'; -import { GlobalModule } from '../global/global.module'; -import type { IClsStore } from '../types/cls'; -import type { IAuthMiddleContext } from './share-db-permission.service'; -import { ShareDbPermissionService } from './share-db-permission.service'; -import { ShareDbModule } from './share-db.module'; -import { WsAuthService } from './ws-auth.service'; - -describe('ShareDBPermissionService', () => { - let shareDbPermissionService: ShareDbPermissionService; - let wsAuthService: WsAuthService; - let clsService: ClsService; - - const shareId = 'shareId'; - const mockUser = { id: 'usr1', name: 'John', email: 'john@example.com' }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [GlobalModule, ShareDbModule], - }).compile(); - - shareDbPermissionService = module.get(ShareDbPermissionService); - wsAuthService = module.get(WsAuthService); - clsService = module.get>(ClsService); - }); - - describe('clsRunWith', () => { - it('should run callback with cls context', async () => { - // mock a context object with agent and custom properties - const context = mockDeep({ - agent: { custom: { user: mockUser, isBackend: false } }, - }); - // mock a callback function - const callback = vi.fn(); - // spy on clsService.set and get methods - const setSpy = vi.spyOn(clsService, 'set'); - const getSpy = vi.spyOn(clsService, 'get'); - // call the clsRunWith method with the context and callback - await shareDbPermissionService['clsRunWith'](context, callback); - // expect the callback to be called once - expect(callback).toHaveBeenCalledTimes(1); - // expect the clsService.set to be called with 'user' and the user object - expect(setSpy).toHaveBeenCalledWith('user', context.agent.custom.user); - // expect the clsService.set to be called with 'user' and the shareId - expect(setSpy).toHaveBeenCalledWith('shareViewId', context.agent.custom.shareId); - // expect the clsService.get to return the user object - expect(getSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('authMiddleware', () => { - it('should call clsRunWith and set user in the CLS context if authentication is successful', async () => { - const context = mockDeep({ - agent: { - custom: { cookie: 'xxxx', sessionId: 'xxxx', isBackend: false, shareId: undefined }, - }, - }); - - const callback = vi.fn(); - - vi.spyOn(wsAuthService, 'checkSession').mockResolvedValue(mockUser); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(shareDbPermissionService as any, 'clsRunWith').mockImplementation(() => ({})); - - await shareDbPermissionService.authMiddleware(context, callback); - - expect(shareDbPermissionService['clsRunWith']).toHaveBeenCalledWith(context, callback); - expect(wsAuthService.checkSession).toHaveBeenCalledWith('xxxx'); - }); - - it('should call the callback without error if the context is from the backend', async () => { - const context = mockDeep({ - agent: { custom: { isBackend: true } }, - }); - const callback = vi.fn(); - - await shareDbPermissionService.authMiddleware(context, callback); - - expect(callback).toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(); - }); - - it('should call the callback with an error if authentication fails', async () => { - const context = mockDeep({ - agent: { custom: { isBackend: false, cookie: 'xxx', shareId: undefined } }, - }); - - const callback = vi.fn(); - - const checkCookieMock = vi - .spyOn(wsAuthService, 'checkSession') - .mockRejectedValue(new Error('Authentication failed')); - - await shareDbPermissionService.authMiddleware(context, callback); - - expect(checkCookieMock).toHaveBeenCalled(); - expect(callback).toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(new Error('Authentication failed')); - }); - - it('should call the callback with share context', async () => { - const context = mockDeep({ - agent: { custom: { cookie: 'xxxx', isBackend: false, shareId } }, - }); - - const callback = vi.fn(); - - vi.spyOn(wsAuthService, 'checkShareCookie').mockImplementation(() => ({}) as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(shareDbPermissionService as any, 'clsRunWith').mockImplementation(() => ({}) as any); - - await shareDbPermissionService.authMiddleware(context, callback); - - expect(shareDbPermissionService['clsRunWith']).toHaveBeenCalledWith(context, callback); - expect(wsAuthService.checkShareCookie).toHaveBeenCalledWith(shareId, 'xxxx'); - }); - }); -}); diff --git a/apps/nestjs-backend/src/share-db/share-db-permission.service.ts b/apps/nestjs-backend/src/share-db/share-db-permission.service.ts deleted file mode 100644 index acbf837fe..000000000 --- a/apps/nestjs-backend/src/share-db/share-db-permission.service.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ANONYMOUS_USER_ID } from '@teable/core'; -import { ClsService } from 'nestjs-cls'; -import type ShareDBClass from 'sharedb'; -import type { IClsStore } from '../types/cls'; -import { WsAuthService } from './ws-auth.service'; - -type IContextDecorator = 'useCls' | 'skipIfBackend'; -// eslint-disable-next-line @typescript-eslint/naming-convention -export function ContextDecorator(...args: IContextDecorator[]): MethodDecorator { - return (_target: unknown, _propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - - descriptor.value = async function ( - context: IAuthMiddleContext, - callback: (err?: unknown) => void - ) { - // Skip if the context is from the backend - if (args.includes('skipIfBackend') && context.agent.custom.isBackend) { - callback(); - return; - } - // If 'useCls' is specified, set up the CLS context - if (args.includes('useCls')) { - const clsService: ClsService = (this as ShareDbPermissionService).clsService; - await clsService.runWith({ ...clsService.get() }, async () => { - try { - clsService.set('user', context.agent.custom.user); - clsService.set('shareViewId', context.agent.custom.shareId); - await originalMethod.apply(this, [context, callback]); - } catch (error) { - callback(error); - } - }); - return; - } - // If 'useCls' is not specified, just call the original method - try { - await originalMethod.apply(this, [context, callback]); - } catch (error) { - callback(error); - } - }; - }; -} - -export type IAuthMiddleContext = - | ShareDBClass.middleware.ConnectContext - | ShareDBClass.middleware.ApplyContext - | ShareDBClass.middleware.ReadSnapshotsContext - | ShareDBClass.middleware.QueryContext; - -@Injectable() -export class ShareDbPermissionService { - constructor( - readonly clsService: ClsService, - private readonly wsAuthService: WsAuthService - ) {} - - private async clsRunWith( - context: IAuthMiddleContext, - callback: (err?: unknown) => void, - error?: unknown - ) { - await this.clsService.runWith(this.clsService.get(), async () => { - this.clsService.set('user', context.agent.custom.user); - this.clsService.set('shareViewId', context.agent.custom.shareId); - callback(error); - }); - } - - @ContextDecorator('skipIfBackend') - async authMiddleware(context: IAuthMiddleContext, callback: (err?: unknown) => void) { - try { - const { cookie, shareId, sessionId } = context.agent.custom; - if (shareId) { - context.agent.custom.user = { id: ANONYMOUS_USER_ID, name: ANONYMOUS_USER_ID, email: '' }; - await this.wsAuthService.checkShareCookie(shareId, cookie); - } else { - const user = await this.wsAuthService.checkSession(sessionId); - context.agent.custom.user = user; - } - await this.clsRunWith(context, callback); - } catch (error) { - callback(error); - } - } -} diff --git a/apps/nestjs-backend/src/share-db/share-db.adapter.ts b/apps/nestjs-backend/src/share-db/share-db.adapter.ts index 4cee2bf17..bae5cab8d 100644 --- a/apps/nestjs-backend/src/share-db/share-db.adapter.ts +++ b/apps/nestjs-backend/src/share-db/share-db.adapter.ts @@ -1,28 +1,20 @@ -import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; -import type { IOtOperation, IRecord } from '@teable/core'; -import { - FieldOpBuilder, - IdPrefix, - RecordOpBuilder, - TableOpBuilder, - ViewOpBuilder, -} from '@teable/core'; +import { Injectable, Logger } from '@nestjs/common'; +import type { IRecord } from '@teable/core'; +import { IdPrefix } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; -import { groupBy } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import type { CreateOp, DeleteOp, EditOp } from 'sharedb'; import ShareDb from 'sharedb'; import type { SnapshotMeta } from 'sharedb/lib/sharedb'; -import { FieldService } from '../features/field/field.service'; -import { RecordService } from '../features/record/record.service'; -import { TableService } from '../features/table/table.service'; -import { ViewService } from '../features/view/view.service'; import type { IClsStore } from '../types/cls'; import { exceptionParse } from '../utils/exception-parse'; -import type { IAdapterService, IReadonlyAdapterService } from './interface'; -import { WsAuthService } from './ws-auth.service'; +import type { IReadonlyAdapterService } from './interface'; +import { FieldReadonlyServiceAdapter } from './readonly/field-readonly.service'; +import { RecordReadonlyServiceAdapter } from './readonly/record-readonly.service'; +import { TableReadonlyServiceAdapter } from './readonly/table-readonly.service'; +import { ViewReadonlyServiceAdapter } from './readonly/view-readonly.service'; export interface ICollectionSnapshot { type: string; @@ -40,25 +32,17 @@ export class ShareDbAdapter extends ShareDb.DB { constructor( private readonly cls: ClsService, - private readonly tableService: TableService, - private readonly recordService: RecordService, - private readonly fieldService: FieldService, - private readonly viewService: ViewService, + private readonly tableService: TableReadonlyServiceAdapter, + private readonly recordService: RecordReadonlyServiceAdapter, + private readonly fieldService: FieldReadonlyServiceAdapter, + private readonly viewService: ViewReadonlyServiceAdapter, private readonly prismaService: PrismaService, - @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, - private readonly wsAuthService: WsAuthService + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) { super(); this.closed = false; } - getService(type: IdPrefix): IAdapterService { - if (IdPrefix.Record === type) { - return this.recordService; - } - throw new ForbiddenException(`QueryType: ${type} has no adapter service implementation`); - } - getReadonlyService(type: IdPrefix): IReadonlyAdapterService { switch (type) { case IdPrefix.View: @@ -109,21 +93,15 @@ export class ShareDbAdapter extends ShareDb.DB { async queryPoll( collection: string, query: unknown, - _options: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any callback: (error: any | null, ids: string[], extra?: any) => void ) { try { - let currentUser = this.cls.get('user'); - const { sessionTicket } = (query ?? {}) as { sessionTicket?: string }; - - if (!currentUser && sessionTicket) { - currentUser = await this.wsAuthService.checkSession(sessionTicket); - } - await this.cls.runWith(this.cls.get(), async () => { - this.cls.set('user', currentUser); - + this.cls.set('cookie', options.cookie); + this.cls.set('shareViewId', options.shareId); const [docType, collectionId] = collection.split('_'); const queryResult = await this.getReadonlyService(docType as IdPrefix).getDocIdsByQuery( @@ -133,6 +111,7 @@ export class ShareDbAdapter extends ShareDb.DB { callback(null, queryResult.ids, queryResult.extra); }); } catch (e) { + this.logger.error(e); // eslint-disable-next-line @typescript-eslint/no-explicit-any callback(exceptionParse(e as Error), []); } @@ -160,121 +139,8 @@ export class ShareDbAdapter extends ShareDb.DB { if (callback) callback(); } - private async updateSnapshot( - version: number, - collection: string, - docId: string, - ops: IOtOperation[] - ) { - const [docType, collectionId] = collection.split('_'); - let opBuilder; - switch (docType as IdPrefix) { - case IdPrefix.View: - opBuilder = ViewOpBuilder; - break; - case IdPrefix.Field: - opBuilder = FieldOpBuilder; - break; - case IdPrefix.Record: - opBuilder = RecordOpBuilder; - break; - case IdPrefix.Table: - opBuilder = TableOpBuilder; - break; - default: - throw new Error(`UpdateSnapshot: ${docType} has no service implementation`); - } - - const ops2Contexts = opBuilder.ops2Contexts(ops); - const service = this.getService(docType as IdPrefix); - // group by op name execute faster - const ops2ContextsGrouped = groupBy(ops2Contexts, 'name'); - for (const opName in ops2ContextsGrouped) { - const opContexts = ops2ContextsGrouped[opName]; - await service.update(version, collectionId, docId, opContexts); - } - } - - private async createSnapshot(collection: string, _docId: string, snapshot: unknown) { - const [docType, collectionId] = collection.split('_'); - await this.getService(docType as IdPrefix).create(collectionId, snapshot); - } - - private async deleteSnapshot(version: number, collection: string, docId: string) { - const [docType, collectionId] = collection.split('_'); - await this.getService(docType as IdPrefix).del(version, collectionId, docId); - } - - // Persists an op and snapshot if it is for the next version. Calls back with - // callback(err, succeeded) - async commit( - collection: string, - id: string, - rawOp: CreateOp | DeleteOp | EditOp, - snapshot: ICollectionSnapshot, - options: unknown, - callback: (err: unknown, succeed?: boolean, complete?: boolean) => void - ) { - /* - * op: CreateOp { - * src: '24545654654646', - * seq: 1, - * v: 0, - * create: { type: 'http://sharejs.org/types/JSONv0', data: { ... } }, - * m: { ts: 12333456456 } } - * } - * snapshot: PostgresSnapshot - */ - - const [docType, collectionId] = collection.split('_'); - - try { - await this.prismaService.$tx(async (prisma) => { - const opsResult = await prisma.ops.aggregate({ - _max: { version: true }, - where: { collection: collectionId, docId: id }, - }); - - if (opsResult._max.version != null) { - const maxVersion = opsResult._max.version + 1; - - if (rawOp.v !== maxVersion) { - this.logger.log({ message: 'op crashed', crashed: rawOp.op }); - throw new Error(`${id} version mismatch: maxVersion: ${maxVersion} rawOpV: ${rawOp.v}`); - } - } - - // 1. save op in db; - await prisma.ops.create({ - data: { - docId: id, - docType, - collection: collectionId, - version: rawOp.v, - operation: JSON.stringify(rawOp), - createdBy: this.cls.get('user.id'), - }, - }); - - // create snapshot - if (rawOp.create) { - await this.createSnapshot(collection, id, rawOp.create.data); - } - - // update snapshot - if (rawOp.op) { - await this.updateSnapshot(snapshot.v, collection, id, rawOp.op); - } - - // delete snapshot - if (rawOp.del) { - await this.deleteSnapshot(snapshot.v, collection, id); - } - }); - callback(null, true, true); - } catch (err) { - callback(exceptionParse(err as Error)); - } + async commit() { + throw new Error('Method not implemented.'); } private snapshots2Map(snapshots: ({ id: string } & T)[]): Record { @@ -302,7 +168,6 @@ export class ShareDbAdapter extends ShareDb.DB { ids, projection && projection['$submit'] ? undefined : projection ); - if (snapshotData.length) { const snapshots = snapshotData.map( (snapshot) => @@ -372,10 +237,9 @@ export class ShareDbAdapter extends ShareDb.DB { .toSQL() .toNative(); - const res = await this.prismaService.$queryRawUnsafe<{ operation: string }[]>( - nativeSql.sql, - ...nativeSql.bindings - ); + const res = await this.prismaService + .txClient() + .$queryRawUnsafe<{ operation: string }[]>(nativeSql.sql, ...nativeSql.bindings); callback( null, diff --git a/apps/nestjs-backend/src/share-db/share-db.module.ts b/apps/nestjs-backend/src/share-db/share-db.module.ts index 9c65e5332..06d3d1db2 100644 --- a/apps/nestjs-backend/src/share-db/share-db.module.ts +++ b/apps/nestjs-backend/src/share-db/share-db.module.ts @@ -1,32 +1,12 @@ import { Module } from '@nestjs/common'; -import { AuthModule } from '../features/auth/auth.module'; -import { SessionHandleModule } from '../features/auth/session/session-handle.module'; -import { CalculationModule } from '../features/calculation/calculation.module'; -import { ShareAuthModule } from '../features/share/share-auth.module'; import { TableModule } from '../features/table/table.module'; -import { UserModule } from '../features/user/user.module'; -import { ShareDbPermissionService } from './share-db-permission.service'; +import { ReadonlyModule } from './readonly/readonly.module'; import { ShareDbAdapter } from './share-db.adapter'; import { ShareDbService } from './share-db.service'; -import { WsAuthService } from './ws-auth.service'; -import { WsDerivateService } from './ws-derivate.service'; @Module({ - imports: [ - TableModule, - CalculationModule, - AuthModule, - UserModule, - ShareAuthModule, - SessionHandleModule, - ], - providers: [ - ShareDbService, - ShareDbAdapter, - WsDerivateService, - WsAuthService, - ShareDbPermissionService, - ], - exports: [ShareDbService, WsAuthService], + imports: [TableModule, ReadonlyModule], + providers: [ShareDbService, ShareDbAdapter], + exports: [ShareDbService], }) export class ShareDbModule {} diff --git a/apps/nestjs-backend/src/share-db/share-db.service.ts b/apps/nestjs-backend/src/share-db/share-db.service.ts index 0e1695a76..a84dd0080 100644 --- a/apps/nestjs-backend/src/share-db/share-db.service.ts +++ b/apps/nestjs-backend/src/share-db/share-db.service.ts @@ -12,11 +12,8 @@ import { EventEmitterService } from '../event-emitter/event-emitter.service'; import type { IClsStore } from '../types/cls'; import { Timing } from '../utils/timing'; import { authMiddleware } from './auth.middleware'; -import { derivateMiddleware } from './derivate.middleware'; import type { IRawOpMap } from './interface'; -import { ShareDbPermissionService } from './share-db-permission.service'; import { ShareDbAdapter } from './share-db.adapter'; -import { WsDerivateService } from './ws-derivate.service'; @Injectable() export class ShareDbService extends ShareDBClass { @@ -27,8 +24,6 @@ export class ShareDbService extends ShareDBClass { private readonly eventEmitterService: EventEmitterService, private readonly prismaService: PrismaService, private readonly cls: ClsService, - private readonly wsDerivateService: WsDerivateService, - private readonly shareDbPermissionService: ShareDbPermissionService, @CacheConfig() private readonly cacheConfig: ICacheConfig ) { super({ @@ -48,10 +43,7 @@ export class ShareDbService extends ShareDBClass { this.pubsub = redisPubsub; } - // auth - authMiddleware(this, this.shareDbPermissionService); - derivateMiddleware(this, this.cls, this.wsDerivateService); - + authMiddleware(this); this.use('submit', this.onSubmit); // broadcast raw op events to client @@ -77,10 +69,7 @@ export class ShareDbService extends ShareDBClass { } getConnection() { - const connection = this.connect(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - connection.agent!.custom.isBackend = true; - return connection; + return this.connect(); } @Timing() @@ -88,6 +77,7 @@ export class ShareDbService extends ShareDBClass { if (!rawOpMaps?.length) { return; } + for (const rawOpMap of rawOpMaps) { for (const collection in rawOpMap) { const data = rawOpMap[collection]; diff --git a/apps/nestjs-backend/src/share-db/utils.ts b/apps/nestjs-backend/src/share-db/utils.ts index 3ae80c103..9a538cb38 100644 --- a/apps/nestjs-backend/src/share-db/utils.ts +++ b/apps/nestjs-backend/src/share-db/utils.ts @@ -28,3 +28,5 @@ export const getAction = (op: CreateOp | DeleteOp | EditOp) => { } return null; }; + +export const getAxiosBaseUrl = () => `http://localhost:${process.env.PORT}/api`; diff --git a/apps/nestjs-backend/src/share-db/ws-auth.service.ts b/apps/nestjs-backend/src/share-db/ws-auth.service.ts deleted file mode 100644 index 8190df7e1..000000000 --- a/apps/nestjs-backend/src/share-db/ws-auth.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { HttpErrorCode } from '@teable/core'; -import cookie from 'cookie'; -import { AUTH_SESSION_COOKIE_NAME } from '../const'; -import { SessionHandleService } from '../features/auth/session/session-handle.service'; -import { ShareAuthService } from '../features/share/share-auth.service'; -import { UserService } from '../features/user/user.service'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const UnauthorizedError = { message: 'Unauthorized', code: HttpErrorCode.UNAUTHORIZED }; - -@Injectable() -export class WsAuthService { - constructor( - private readonly userService: UserService, - private readonly shareAuthService: ShareAuthService, - private readonly sessionHandleService: SessionHandleService - ) {} - - async checkSession(sessionId: string | undefined) { - if (sessionId) { - try { - return await this.auth(sessionId); - } catch { - throw UnauthorizedError; - } - } else { - throw UnauthorizedError; - } - } - - async auth(sessionId: string) { - const userId = await this.sessionHandleService.getUserId(sessionId); - if (!userId) { - throw new UnauthorizedException(); - } - try { - const user = await this.userService.getUserById(userId); - if (!user) { - throw new UnauthorizedException(); - } - return { id: user.id, email: user.email, name: user.name }; - } catch (error) { - throw new UnauthorizedException(); - } - } - - static extractSessionFromHeader(cookieStr: string): string | undefined { - const cookieObj = cookie.parse(cookieStr); - return cookieObj[AUTH_SESSION_COOKIE_NAME]; - } - - async checkShareCookie(shareId: string, cookie?: string) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const UnauthorizedError = { message: 'Unauthorized', code: HttpErrorCode.UNAUTHORIZED_SHARE }; - try { - return await this.authShare(shareId, cookie); - } catch { - throw UnauthorizedError; - } - } - - async authShare(shareId: string, cookie?: string) { - const { view } = await this.shareAuthService.getShareViewInfo(shareId); - const hasPassword = view.shareMeta?.password; - if (!hasPassword) { - return; - } - if (!cookie) { - throw new UnauthorizedException(); - } - const token = WsAuthService.extractShareTokenFromHeader(cookie, shareId); - if (!token) { - throw new UnauthorizedException(); - } - try { - const jwtShare = await this.shareAuthService.validateJwtToken(token); - const shareAuthId = await this.shareAuthService.authShareView( - jwtShare.shareId, - jwtShare.password - ); - if (!shareAuthId || shareAuthId !== shareId) { - throw new UnauthorizedException(); - } - return { shareId }; - } catch (error) { - throw new UnauthorizedException(); - } - } - - static extractShareTokenFromHeader(cookieStr: string, shareId: string): string | null { - const cookieObj = cookie.parse(cookieStr); - return cookieObj[shareId]; - } -} diff --git a/apps/nestjs-backend/src/share-db/ws-derivate.service.spec.ts b/apps/nestjs-backend/src/share-db/ws-derivate.service.spec.ts deleted file mode 100644 index a0ed1fee7..000000000 --- a/apps/nestjs-backend/src/share-db/ws-derivate.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { TestingModule } from '@nestjs/testing'; -import { Test } from '@nestjs/testing'; -import { GlobalModule } from '../global/global.module'; -import { ShareDbModule } from './share-db.module'; -import { WsDerivateService } from './ws-derivate.service'; - -describe('WsDerivateService', () => { - let service: WsDerivateService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [GlobalModule, ShareDbModule], - }).compile(); - - service = module.get(WsDerivateService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/nestjs-backend/src/share-db/ws-derivate.service.ts b/apps/nestjs-backend/src/share-db/ws-derivate.service.ts deleted file mode 100644 index 3dee75b35..000000000 --- a/apps/nestjs-backend/src/share-db/ws-derivate.service.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import type { IOtOperation } from '@teable/core'; -import { IdPrefix, RecordOpBuilder } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; -import { isEmpty, pick } from 'lodash'; -import { ClsService } from 'nestjs-cls'; -import type ShareDb from 'sharedb'; -import { BatchService } from '../features/calculation/batch.service'; -import { LinkService } from '../features/calculation/link.service'; -import type { IOpsMap } from '../features/calculation/reference.service'; -import { ReferenceService } from '../features/calculation/reference.service'; -import { SystemFieldService } from '../features/calculation/system-field.service'; -import type { ICellChange } from '../features/calculation/utils/changes'; -import { formatChangesToOps } from '../features/calculation/utils/changes'; -import { composeOpMaps } from '../features/calculation/utils/compose-maps'; -import type { IFieldInstance } from '../features/field/model/factory'; -import type { IClsStore } from '../types/cls'; -import type { IRawOp, IRawOpMap } from './interface'; - -export interface ISaveContext { - opsMap: IOpsMap; - fieldMap: Record; - tableId2DbTableName: Record; -} - -export type ICustomSubmitContext = ShareDb.middleware.SubmitContext & { - extra: { source?: unknown; saveContext?: ISaveContext; stashOpMap?: IRawOpMap }; -}; -export type ICustomApplyContext = ShareDb.middleware.ApplyContext & { - extra: { source?: unknown; saveContext?: ISaveContext; stashOpMap?: IRawOpMap }; -}; - -@Injectable() -export class WsDerivateService { - private logger = new Logger(WsDerivateService.name); - - constructor( - private readonly linkService: LinkService, - private readonly referenceService: ReferenceService, - private readonly batchService: BatchService, - private readonly prismaService: PrismaService, - private readonly systemFieldService: SystemFieldService, - private readonly cls: ClsService - ) {} - - async calculate(changes: ICellChange[]) { - if (new Set(changes.map((c) => c.tableId)).size > 1) { - throw new Error('Invalid changes, contains multiple tableId in 1 transaction'); - } - - if (!changes.length) { - return; - } - - const derivate = await this.linkService.getDerivateByLink(changes[0].tableId, changes); - const cellChanges = derivate?.cellChanges || []; - - const opsMapOrigin = formatChangesToOps(changes); - const opsMapByLink = formatChangesToOps(cellChanges); - const composedOpsMap = composeOpMaps([opsMapOrigin, opsMapByLink]); - const systemFieldOpsMap = await this.systemFieldService.getOpsMapBySystemField(composedOpsMap); - - const { - opsMap: opsMapByCalculate, - fieldMap, - tableId2DbTableName, - } = await this.referenceService.calculateOpsMap(composedOpsMap, derivate?.saveForeignKeyToDb); - const composedMap = composeOpMaps([opsMapByLink, opsMapByCalculate, systemFieldOpsMap]); - - // console.log('socket:final:opsMap', JSON.stringify(composedMap, null, 2)); - - if (isEmpty(composedMap)) { - return; - } - - return { - opsMap: composedMap, - fieldMap, - tableId2DbTableName, - }; - } - - async save(saveContext: ISaveContext) { - const { opsMap, fieldMap, tableId2DbTableName } = saveContext; - await this.prismaService.$tx(async () => { - return await this.batchService.updateRecords(opsMap, fieldMap, tableId2DbTableName); - }); - } - - private op2Changes(tableId: string, recordId: string, ops: IOtOperation[]) { - return ops.reduce((pre, cur) => { - const ctx = RecordOpBuilder.editor.setRecord.detect(cur); - if (ctx) { - pre.push({ - tableId: tableId, - recordId: recordId, - fieldId: ctx.fieldId, - oldValue: ctx.oldCellValue, - newValue: ctx.newCellValue, - }); - } - return pre; - }, []); - } - - async onRecordApply(context: ICustomApplyContext, next: (err?: unknown) => void) { - const [docType, tableId] = context.collection.split('_') as [IdPrefix, string]; - const recordId = context.id; - if (docType !== IdPrefix.Record || !context.op.op) { - // TODO: Capture some missed situations, which may be deleted later. - this.stashOpMap(context, true); - return next(); - } - - this.logger.log('onRecordApply: ' + JSON.stringify(context.op.op, null, 2)); - const changes = this.op2Changes(tableId, recordId, context.op.op); - if (!changes.length) { - // TODO: Capture some missed situations, which may be deleted later. - this.stashOpMap(context, true); - return next(); - } - - try { - const saveContext = await this.prismaService.$tx(async () => { - return await this.calculate(changes); - }); - if (saveContext) { - context.extra.saveContext = saveContext; - context.extra.stashOpMap = this.stashOpMap(context); - } else { - this.stashOpMap(context, true); - } - } catch (e) { - return next(e); - } - - next(); - } - - private stashOpMap(context: ShareDb.middleware.SubmitContext, preSave: boolean = false) { - const { collection, id, op } = context; - const stashOpMap: IRawOpMap = { [collection]: {} }; - - stashOpMap[collection][id] = pick(op, [ - 'src', - 'seq', - 'm', - 'create', - 'op', - 'del', - 'v', - 'c', - 'd', - ]) as IRawOp; - - if (preSave) { - this.cls.set('tx.stashOpMap', stashOpMap); - } - return stashOpMap; - } -} diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index e5cbd10d7..531e673b4 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -19,4 +19,6 @@ export interface IClsStore extends ClsStore { }; shareViewId?: string; permissions: PermissionAction[]; + // for share db adapter + cookie?: string; } diff --git a/apps/nestjs-backend/src/utils/exception-parse.ts b/apps/nestjs-backend/src/utils/exception-parse.ts index c5ff39c6a..c88c85446 100644 --- a/apps/nestjs-backend/src/utils/exception-parse.ts +++ b/apps/nestjs-backend/src/utils/exception-parse.ts @@ -1,10 +1,13 @@ import { HttpException } from '@nestjs/common'; -import { HttpErrorCode } from '@teable/core'; +import { HttpErrorCode, HttpError } from '@teable/core'; import { CustomHttpException, getDefaultCodeByStatus } from '../custom.exception'; export const exceptionParse = ( - exception: Error | HttpException | CustomHttpException + exception: Error | HttpException | CustomHttpException | HttpError ): CustomHttpException => { + if (exception instanceof HttpError) { + return new CustomHttpException(exception.message, exception.code); + } if (exception instanceof CustomHttpException) { return exception; } diff --git a/apps/nestjs-backend/src/ws/ws.gateway.dev.ts b/apps/nestjs-backend/src/ws/ws.gateway.dev.ts index f8c589c30..19bfe7606 100644 --- a/apps/nestjs-backend/src/ws/ws.gateway.dev.ts +++ b/apps/nestjs-backend/src/ws/ws.gateway.dev.ts @@ -1,4 +1,3 @@ -import url from 'url'; import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -6,9 +5,7 @@ import WebSocketJSONStream from '@teamwork/websocket-json-stream'; import type { Request } from 'express'; import type { WebSocket } from 'ws'; import { Server } from 'ws'; -import { SessionHandleService } from '../features/auth/session/session-handle.service'; import { ShareDbService } from '../share-db/share-db.service'; -import { WsAuthService } from '../share-db/ws-auth.service'; @Injectable() export class DevWsGateway implements OnModuleInit, OnModuleDestroy { @@ -20,23 +17,12 @@ export class DevWsGateway implements OnModuleInit, OnModuleDestroy { constructor( private readonly shareDb: ShareDbService, - private readonly configService: ConfigService, - private readonly wsAuthService: WsAuthService, - private readonly sessionHandleService: SessionHandleService + private readonly configService: ConfigService ) {} handleConnection = async (webSocket: WebSocket, request: Request) => { this.logger.log('ws:on:connection'); try { - const newUrl = new url.URL(request.url, 'https://example.com'); - const shareId = newUrl.searchParams.get('shareId'); - if (shareId) { - const cookie = request.headers.cookie; - await this.wsAuthService.checkShareCookie(shareId, cookie); - } else { - const sessionId = await this.sessionHandleService.getSessionIdFromRequest(request); - await this.wsAuthService.checkSession(sessionId); - } const stream = new WebSocketJSONStream(webSocket); const agent = this.shareDb.listen(stream, request); this.agents.push(agent); diff --git a/apps/nestjs-backend/src/ws/ws.gateway.ts b/apps/nestjs-backend/src/ws/ws.gateway.ts index 31218247b..463b85be4 100644 --- a/apps/nestjs-backend/src/ws/ws.gateway.ts +++ b/apps/nestjs-backend/src/ws/ws.gateway.ts @@ -1,23 +1,16 @@ -import url from 'url'; import { Logger } from '@nestjs/common'; import type { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit } from '@nestjs/websockets'; import { WebSocketGateway } from '@nestjs/websockets'; import WebSocketJSONStream from '@teamwork/websocket-json-stream'; import type { Request } from 'express'; import type { Server } from 'ws'; -import { SessionHandleService } from '../features/auth/session/session-handle.service'; import { ShareDbService } from '../share-db/share-db.service'; -import { WsAuthService } from '../share-db/ws-auth.service'; @WebSocketGateway({ path: '/socket', perMessageDeflate: true }) export class WsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { private logger = new Logger(WsGateway.name); - constructor( - private readonly shareDb: ShareDbService, - private readonly wsAuthService: WsAuthService, - private readonly sessionHandleService: SessionHandleService - ) {} + constructor(private readonly shareDb: ShareDbService) {} handleDisconnect() { this.logger.log('ws:on:close'); @@ -31,15 +24,6 @@ export class WsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayD this.logger.log('WsGateway afterInit'); server.on('connection', async (webSocket, request: Request) => { try { - const newUrl = new url.URL(request.url || '', 'https://example.com'); - const shareId = newUrl.searchParams.get('shareId'); - if (shareId) { - const cookie = request.headers.cookie; - await this.wsAuthService.checkShareCookie(shareId, cookie); - } else { - const sessionId = await this.sessionHandleService.getSessionIdFromRequest(request); - await this.wsAuthService.checkSession(sessionId); - } this.logger.log('ws:on:connection'); const stream = new WebSocketJSONStream(webSocket); this.shareDb.listen(stream, request); diff --git a/apps/nestjs-backend/src/ws/ws.module.ts b/apps/nestjs-backend/src/ws/ws.module.ts index a66631493..b8bd04d0a 100644 --- a/apps/nestjs-backend/src/ws/ws.module.ts +++ b/apps/nestjs-backend/src/ws/ws.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common'; -import { SessionHandleModule } from '../features/auth/session/session-handle.module'; import { ShareDbModule } from '../share-db/share-db.module'; import { WsGateway } from './ws.gateway'; import { DevWsGateway } from './ws.gateway.dev'; import { WsService } from './ws.service'; @Module({ - imports: [ShareDbModule, SessionHandleModule], + imports: [ShareDbModule], providers: [WsService, process.env.NODE_ENV === 'production' ? WsGateway : DevWsGateway], }) export class WsModule {} diff --git a/apps/nestjs-backend/test/link-api.e2e-spec.ts b/apps/nestjs-backend/test/link-api.e2e-spec.ts index fb8db9c61..a181433fd 100644 --- a/apps/nestjs-backend/test/link-api.e2e-spec.ts +++ b/apps/nestjs-backend/test/link-api.e2e-spec.ts @@ -4,19 +4,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; -import type { IFieldRo, ILinkFieldOptions, ILookupOptionsVo, IRecord } from '@teable/core'; -import { - FieldKeyType, - FieldType, - IdPrefix, - NumberFormattingType, - RecordOpBuilder, - Relationship, -} from '@teable/core'; +import type { IFieldRo, ILinkFieldOptions, ILookupOptionsVo } from '@teable/core'; +import { FieldKeyType, FieldType, NumberFormattingType, Relationship } from '@teable/core'; import type { ITableFullVo } from '@teable/openapi'; import { updateDbTableName } from '@teable/openapi'; -import type { Connection, Doc } from 'sharedb/lib/client'; -import { ShareDbService } from '../src/share-db/share-db.service'; import { createField, createRecords, @@ -38,54 +29,16 @@ describe('OpenAPI link (e2e)', () => { let app: INestApplication; const baseId = globalThis.testConfig.baseId; const split = globalThis.testConfig.driver === 'postgresql' ? '.' : '_'; - let connection: Connection; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; - - const shareDbService = app.get(ShareDbService); - connection = shareDbService.connect(undefined, { - headers: { - cookie: appCtx.cookie, - }, - sessionID: appCtx.sessionID, - }); }); afterAll(async () => { await app.close(); }); - async function updateRecordViaShareDb( - tableId: string, - recordId: string, - fieldId: string, - newValues: any - ) { - const collection = `${IdPrefix.Record}_${tableId}`; - return new Promise((resolve, reject) => { - const doc: Doc = connection.get(collection, recordId); - doc.fetch((err) => { - if (err) { - return reject(err); - } - const op = RecordOpBuilder.editor.setRecord.build({ - fieldId, - oldCellValue: doc.data.fields[fieldId], - newCellValue: newValues, - }); - - doc.submitOp(op, undefined, (err) => { - if (err) { - return reject(err); - } - resolve(doc.data); - }); - }); - }); - } - describe('create table with link field', () => { let table1: ITableFullVo; let table2: ITableFullVo; @@ -1710,46 +1663,6 @@ describe('OpenAPI link (e2e)', () => { 400 ); }); - - it('should safe delete a link record in cell via socket', async () => { - await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, { - title: 'A1', - id: table1.records[0].id, - }); - - await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, null); - const record1 = await getRecord(table2.id, table2.records[0].id); - expect(record1.fields[table2.fields[2].id]).toBeUndefined(); - - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[2].id, { - title: 'A1', - id: table1.records[0].id, - }); - - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[2].id, null); - - const record2 = await getRecord(table2.id, table2.records[0].id); - expect(record2.fields[table2.fields[2].id]).toBeUndefined(); - - const linkFieldRo: IFieldRo = { - name: 'link field', - type: FieldType.Link, - options: { - relationship: Relationship.OneOne, - foreignTableId: table1.id, - isOneWay: type === 'isOneWay', - }, - }; - const linkField = await createField(table2.id, linkFieldRo); - - await updateRecordViaShareDb(table2.id, table2.records[0].id, linkField.id, { - id: table1.records[0].id, - }); - - await updateRecordViaShareDb(table2.id, table2.records[0].id, linkField.id, null); - const record3 = await getRecord(table2.id, table2.records[0].id); - expect(record3.fields[linkField.id]).toBeUndefined(); - }); } ); diff --git a/apps/nestjs-backend/test/link-socket.e2e-spec.ts b/apps/nestjs-backend/test/link-socket.e2e-spec.ts deleted file mode 100644 index afc776d2a..000000000 --- a/apps/nestjs-backend/test/link-socket.e2e-spec.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * test case for simulate frontend collaboration data flow - */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable sonarjs/no-duplicate-string */ -import type { INestApplication } from '@nestjs/common'; -import type { IFieldRo, IRecord } from '@teable/core'; -import { - RecordOpBuilder, - IdPrefix, - FieldType, - Relationship, - NumberFormattingType, -} from '@teable/core'; -import type { ITableFullVo } from '@teable/openapi'; -import type { Doc } from 'sharedb/lib/client'; -import { ShareDbService } from '../src/share-db/share-db.service'; -import { - deleteTable, - createTable, - initApp, - getFields, - getRecords, - createField, -} from './utils/init-app'; - -describe('OpenAPI link (socket-e2e)', () => { - let app: INestApplication; - let table1: ITableFullVo; - let table2: ITableFullVo; - let shareDbService!: ShareDbService; - const baseId = globalThis.testConfig.baseId; - let cookie: string; - let sessionID: string; - - beforeAll(async () => { - const appCtx = await initApp(); - app = appCtx.app; - cookie = appCtx.cookie; - sessionID = appCtx.sessionID; - - shareDbService = app.get(ShareDbService); - }); - - afterAll(async () => { - await app.close(); - }); - - afterEach(async () => { - await deleteTable(baseId, table1.id); - await deleteTable(baseId, table2.id); - }); - - describe('link field cell update', () => { - beforeEach(async () => { - const numberFieldRo: IFieldRo = { - name: 'Number field', - type: FieldType.Number, - options: { - formatting: { type: NumberFormattingType.Decimal, precision: 1 }, - }, - }; - - const textFieldRo: IFieldRo = { - name: 'text field', - type: FieldType.SingleLineText, - }; - - table1 = await createTable(baseId, { - name: 'table1', - fields: [textFieldRo, numberFieldRo], - records: [ - { fields: { 'text field': 'A1' } }, - { fields: { 'text field': 'A2' } }, - { fields: { 'text field': 'A3' } }, - ], - }); - - const table2LinkFieldRo: IFieldRo = { - name: 'link field', - type: FieldType.Link, - options: { - relationship: Relationship.ManyOne, - foreignTableId: table1.id, - }, - }; - - // table2 link manyOne table1 - table2 = await createTable(baseId, { - name: 'table2', - fields: [textFieldRo, numberFieldRo, table2LinkFieldRo], - records: [ - { fields: { 'text field': 'B1' } }, - { fields: { 'text field': 'B2' } }, - { fields: { 'text field': 'B3' } }, - ], - }); - - table1.fields = await getFields(table1.id); - }); - - async function updateRecordViaShareDb( - tableId: string, - recordId: string, - fieldId: string, - newValues: any - ) { - const connection = shareDbService.connect(undefined, { - headers: { - cookie, - }, - sessionID, - }); - const collection = `${IdPrefix.Record}_${tableId}`; - return new Promise((resolve, reject) => { - const doc: Doc = connection.get(collection, recordId); - doc.fetch((err) => { - if (err) { - return reject(err); - } - const op = RecordOpBuilder.editor.setRecord.build({ - fieldId, - oldCellValue: doc.data.fields[fieldId], - newCellValue: newValues, - }); - - doc.submitOp(op, undefined, (err) => { - if (err) { - return reject(err); - } - resolve(doc.data); - }); - }); - }); - } - - it('should update foreign link field when set a new link in to link field cell', async () => { - // t2[0](many) -> t1[1](one) - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[2].id, { - title: 'test', - id: table1.records[1].id, - }); - - const table2RecordResult = await getRecords(table2.id); - expect(table2RecordResult.records[0].fields[table2.fields[2].name]).toEqual({ - title: 'A2', - id: table1.records[1].id, - }); - - const table1RecordResult2 = await getRecords(table1.id); - // t1[0](one) should be undefined; - expect(table1RecordResult2.records[1].fields[table1.fields[2].name!]).toEqual([ - { - title: 'B1', - id: table2.records[0].id, - }, - ]); - expect(table1RecordResult2.records[0].fields[table1.fields[2].name!]).toBeUndefined(); - }); - - it('should update foreign link field when change lookupField value', async () => { - // set text for lookup field - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[0].id, 'B1'); - await updateRecordViaShareDb(table2.id, table2.records[1].id, table2.fields[0].id, 'B2'); - - // add an extra link for table1 record1 - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[2].id, { - title: 'A1', - id: table1.records[0].id, - }); - await updateRecordViaShareDb(table2.id, table2.records[1].id, table2.fields[2].id, { - title: 'A1', - id: table1.records[0].id, - }); - - const table1RecordResult2 = await getRecords(table1.id); - expect(table1RecordResult2.records[0].fields[table1.fields[2].name!]).toEqual([ - { - title: 'B1', - id: table2.records[0].id, - }, - { - title: 'B2', - id: table2.records[1].id, - }, - ]); - - await updateRecordViaShareDb(table1.id, table1.records[0].id, table1.fields[0].id, 'AX'); - - const table2RecordResult2 = await getRecords(table2.id); - expect(table2RecordResult2.records[0].fields[table2.fields[2].name!]).toEqual({ - title: 'AX', - id: table1.records[0].id, - }); - }); - - it('should update self foreign link with correct title', async () => { - // set text for lookup field - - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[0].id, 'B1'); - await updateRecordViaShareDb(table2.id, table2.records[1].id, table2.fields[0].id, 'B2'); - await updateRecordViaShareDb(table1.id, table1.records[0].id, table1.fields[2].id, [ - { title: 'B1', id: table2.records[0].id }, - { title: 'B2', id: table2.records[1].id }, - ]); - - const table1RecordResult2 = await getRecords(table1.id); - - expect(table1RecordResult2.records[0].fields[table1.fields[2].name!]).toEqual([ - { - title: 'B1', - id: table2.records[0].id, - }, - { - title: 'B2', - id: table2.records[1].id, - }, - ]); - }); - - it('should update formula field when change manyOne link cell', async () => { - const table2FormulaFieldRo: IFieldRo = { - name: 'table2Formula', - type: FieldType.Formula, - options: { - expression: `{${table2.fields[2].id}}`, - }, - }; - - await createField(table2.id, table2FormulaFieldRo); - - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[2].id, { - title: 'test1', - id: table1.records[1].id, - }); - - const table1RecordResult = await getRecords(table1.id); - - const table2RecordResult = await getRecords(table2.id); - - expect(table1RecordResult.records[0].fields[table1.fields[2].name!]).toBeUndefined(); - - expect(table1RecordResult.records[1].fields[table1.fields[2].name!]).toEqual([ - { - title: 'B1', - id: table2.records[0].id, - }, - ]); - - expect(table2RecordResult.records[0].fields[table2FormulaFieldRo.name!]).toEqual('A2'); - }); - - it('should update formula field when change oneMany link cell', async () => { - const table1FormulaFieldRo: IFieldRo = { - name: 'table1 formula field', - type: FieldType.Formula, - options: { - expression: `{${table1.fields[2].id}}`, - }, - }; - - await createField(table1.id, table1FormulaFieldRo); - - await updateRecordViaShareDb(table1.id, table1.records[0].id, table1.fields[2].id, [ - { title: 'test1', id: table2.records[0].id }, - { title: 'test2', id: table2.records[1].id }, - ]); - - const table1RecordResult = await getRecords(table1.id); - - expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toEqual([ - { title: 'B1', id: table2.records[0].id }, - { title: 'B2', id: table2.records[1].id }, - ]); - - expect(table1RecordResult.records[0].fields[table1FormulaFieldRo.name!]).toEqual([ - 'B1', - 'B2', - ]); - }); - - it('should update oneMany formula field when change oneMany link cell', async () => { - const table1FormulaFieldRo: IFieldRo = { - name: 'table1 formula field', - type: FieldType.Formula, - options: { - expression: `{${table1.fields[2].id}}`, - }, - }; - await createField(table1.id, table1FormulaFieldRo as IFieldRo); - - const table2FormulaFieldRo: IFieldRo = { - name: 'table2 formula field', - type: FieldType.Formula, - options: { - expression: `{${table2.fields[2].id}}`, - }, - }; - await createField(table2.id, table2FormulaFieldRo as IFieldRo); - - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[2].id, { - title: 'A1', - id: table1.records[0].id, - }); - - await updateRecordViaShareDb(table2.id, table2.records[1].id, table2.fields[2].id, { - title: 'A2', - id: table1.records[1].id, - }); - - // table2 record2 link from A2 to A1 - await updateRecordViaShareDb(table2.id, table2.records[1].id, table2.fields[2].id, { - title: 'A1', - id: table1.records[0].id, - }); - - const table1RecordResult = (await getRecords(table1.id)).records; - const table2RecordResult = (await getRecords(table2.id)).records; - - expect(table1RecordResult[0].fields[table1FormulaFieldRo.name!]).toEqual(['B1', 'B2']); - expect(table1RecordResult[1].fields[table1FormulaFieldRo.name!]).toEqual(undefined); - expect(table2RecordResult[0].fields[table2FormulaFieldRo.name!]).toEqual('A1'); - expect(table2RecordResult[1].fields[table2FormulaFieldRo.name!]).toEqual('A1'); - }); - - it('should throw error when add a duplicate record in oneMany link field', async () => { - // set text for lookup field - await updateRecordViaShareDb(table2.id, table2.records[0].id, table2.fields[0].id, 'B1'); - await updateRecordViaShareDb(table2.id, table2.records[1].id, table2.fields[0].id, 'B2'); - - // first update - await updateRecordViaShareDb(table1.id, table1.records[0].id, table1.fields[2].id, [ - { title: 'B1', id: table2.records[0].id }, - { title: 'B2', id: table2.records[1].id }, - ]); - - // // update a duplicated link record in other record - await expect( - updateRecordViaShareDb(table1.id, table1.records[1].id, table1.fields[2].id, [ - { title: 'B1', id: table2.records[0].id }, - ]) - ).rejects.toThrow(); - - const table1RecordResult2 = await getRecords(table1.id); - - expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([ - { title: 'B1', id: table2.records[0].id }, - { title: 'B2', id: table2.records[1].id }, - ]); - - expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toBeUndefined(); - }); - }); -}); diff --git a/apps/nestjs-backend/test/share-socket.e2e-spec.ts b/apps/nestjs-backend/test/share-socket.e2e-spec.ts index 4c37622e9..626b694dd 100644 --- a/apps/nestjs-backend/test/share-socket.e2e-spec.ts +++ b/apps/nestjs-backend/test/share-socket.e2e-spec.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { INestApplication } from '@nestjs/common'; -import { HttpErrorCode, IdPrefix, RecordOpBuilder, ViewType } from '@teable/core'; +import { IdPrefix, ViewType } from '@teable/core'; import { enableShareView as apiEnableShareView } from '@teable/openapi'; import { map } from 'lodash'; -import { logger, type Doc } from 'sharedb/lib/client'; -import { vi } from 'vitest'; +import { type Doc } from 'sharedb/lib/client'; import { ShareDbService } from '../src/share-db/share-db.service'; +import { getError } from './utils/get-error'; import { initApp, updateViewColumnMeta, createTable, deleteTable } from './utils/init-app'; describe('Share (socket-e2e) (e2e)', () => { @@ -88,35 +88,7 @@ describe('Share (socket-e2e) (e2e)', () => { it('shareId error', async () => { const collection = `${IdPrefix.View}_${tableId}`; - const consoleWarnSpy = vi.spyOn(logger, 'warn'); - await expect(getQuery(collection, 'share')).rejects.toThrow(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Agent closed due to error', - expect.anything(), - expect.objectContaining({ - message: 'Unauthorized', - code: 'unauthorized_share', - }) - ); - }); - - it('cant not update record in share page', async () => { - const collection = `${IdPrefix.Record}_${tableId}`; - const docs = await getQuery(collection, shareId); - const operation = RecordOpBuilder.editor.setRecord.build({ - fieldId: fieldIds[0], - newCellValue: '1', - oldCellValue: docs[0].data.fields[fieldIds[0]], - }); - const error = await new Promise((resolve) => { - docs[0].submitOp(operation, undefined, (error) => { - resolve(error); - }); - }); - expect(error).toEqual( - expect.objectContaining({ - code: HttpErrorCode.RESTRICTED_RESOURCE, - }) - ); + const error = await getError(() => getQuery(collection, 'error')); + expect(error?.code).toEqual('unauthorized_share'); }); }); diff --git a/apps/nestjs-backend/test/utils/init-app.ts b/apps/nestjs-backend/test/utils/init-app.ts index 3515950e5..e7450f665 100644 --- a/apps/nestjs-backend/test/utils/init-app.ts +++ b/apps/nestjs-backend/test/utils/init-app.ts @@ -90,10 +90,12 @@ export async function initApp() { await app.listen(0); const nestUrl = await app.getUrl(); - const url = `http://127.0.0.1:${new URL(nestUrl).port}`; + const port = new URL(nestUrl).port; + const url = `http://127.0.0.1:${port}`; console.log('url', url); + process.env.PORT = port; process.env.STORAGE_PREFIX = url; axios.defaults.baseURL = url + '/api'; diff --git a/packages/sdk/src/context/app/AppContext.ts b/packages/sdk/src/context/app/AppContext.ts index 339f78ff5..851b6d83c 100644 --- a/packages/sdk/src/context/app/AppContext.ts +++ b/packages/sdk/src/context/app/AppContext.ts @@ -16,6 +16,7 @@ export interface IAppContext { isAutoTheme: boolean; locale: ILocale; lang?: string; + shareId?: string; setTheme: (theme: ThemeKey | null) => void; } diff --git a/packages/sdk/src/context/use-instances/useInstances.ts b/packages/sdk/src/context/use-instances/useInstances.ts index 6471b9aeb..1b391e790 100644 --- a/packages/sdk/src/context/use-instances/useInstances.ts +++ b/packages/sdk/src/context/use-instances/useInstances.ts @@ -1,7 +1,6 @@ import { isEqual } from 'lodash'; -import { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react'; import type { Doc, Query } from 'sharedb/lib/client'; -import { useSession } from '../../hooks'; import { AppContext } from '../app/AppContext'; import { OpListenersManager } from './opListener'; import type { IInstanceAction } from './reducer'; @@ -48,15 +47,6 @@ export function useInstances({ (state: R[], action: IInstanceAction) => instanceReducer(state, action, factory), initData && !connected ? initData.map((data) => factory(data)) : [] ); - const { user: sessionUser } = useSession(); - - const newQueryParams = useMemo( - () => ({ - ...(queryParams || {}), - sessionTicket: (sessionUser as unknown as { _session_ticket?: string })?._session_ticket, - }), - [queryParams, sessionUser] - ); const opListeners = useRef>(new OpListenersManager(collection)); const preQueryRef = useRef>(); @@ -65,8 +55,7 @@ export function useInstances({ console.log( `${query.collection}:ready:`, (() => { - const { sessionTicket: _, ...logQuery } = query.query; - return logQuery; + return query.query; })() ); if (!query.results) { @@ -75,7 +64,7 @@ export function useInstances({ dispatch({ type: 'ready', results: query.results }); query.results.forEach((doc) => { opListeners.current.add(doc, (op) => { - console.log(`${query.collection} on op:`, op); + console.log(`${query.collection} on op:`, op, doc); dispatch({ type: 'update', doc }); }); }); @@ -124,16 +113,16 @@ export function useInstances({ if (!collection || !connection) { return undefined; } - if (query && isEqual(newQueryParams, query.query) && collection === query.collection) { + if (query && isEqual(queryParams, query.query) && collection === query.collection) { return query; } queryDestroy(preQueryRef.current); - const newQuery = connection.createSubscribeQuery(collection, newQueryParams); + const newQuery = connection.createSubscribeQuery(collection, queryParams); preQueryRef.current = newQuery; return newQuery; }); - }, [connection, collection, newQueryParams]); + }, [connection, collection, queryParams]); useEffect(() => { return () => { diff --git a/packages/sdk/src/model/record/record.ts b/packages/sdk/src/model/record/record.ts index 93367606e..a16423e02 100644 --- a/packages/sdk/src/model/record/record.ts +++ b/packages/sdk/src/model/record/record.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { IRecord } from '@teable/core'; -import { RecordOpBuilder, RecordCore } from '@teable/core'; +import { RecordCore, FieldKeyType, RecordOpBuilder } from '@teable/core'; import { createRecords, getRecords, updateRecord, updateRecordOrders } from '@teable/openapi'; import type { Doc } from 'sharedb/lib/client'; import { requestWrap } from '../../utils/requestWrap'; @@ -23,17 +23,23 @@ export class Record extends RecordCore { } async updateCell(fieldId: string, cellValue: unknown) { - const operation = RecordOpBuilder.editor.setRecord.build({ - fieldId, - newCellValue: cellValue, - oldCellValue: this.fields[fieldId], - }); - try { - return await new Promise((resolve, reject) => { - this.doc.submitOp([operation], undefined, (error) => { - error ? reject(error) : resolve(undefined); - }); + const operation = RecordOpBuilder.editor.setRecord.build({ + fieldId, + newCellValue: cellValue, + oldCellValue: this.fields[fieldId], + }); + this.doc.data.fields[fieldId] = cellValue; + this.doc.emit('op', [operation], false, ''); + this.fields[fieldId] = cellValue; + const [, tableId] = this.doc.collection.split('_'); + await Record.updateRecord(tableId, this.doc.id, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [fieldId]: cellValue, + }, + }, }); } catch (error) { return error;