diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 2b36f5f3b..ad99a86e6 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -159,6 +159,7 @@ "fs-extra": "11.2.0", "handlebars": "4.7.8", "helmet": "7.1.0", + "http-proxy-middleware": "3.0.2", "ioredis": "5.4.1", "is-port-reachable": "3.1.0", "joi": "17.12.2", diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index bf108c597..83f3d0830 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from './features/auth/auth.module'; import { BaseModule } from './features/base/base.module'; import { ChatModule } from './features/chat/chat.module'; import { CollaboratorModule } from './features/collaborator/collaborator.module'; +import { DashboardModule } from './features/dashboard/dashboard.module'; import { ExportOpenApiModule } from './features/export/open-api/export-open-api.module'; import { FieldOpenApiModule } from './features/field/open-api/field-open-api.module'; import { HealthModule } from './features/health/health.module'; @@ -16,6 +17,7 @@ import { NextModule } from './features/next/next.module'; import { NotificationModule } from './features/notification/notification.module'; import { OAuthModule } from './features/oauth/oauth.module'; import { PinModule } from './features/pin/pin.module'; +import { PluginModule } from './features/plugin/plugin.module'; import { SelectionModule } from './features/selection/selection.module'; import { SettingModule } from './features/setting/setting.module'; import { ShareModule } from './features/share/share.module'; @@ -55,6 +57,8 @@ export const appModules = { SettingModule, OAuthModule, TrashModule, + PluginModule, + DashboardModule, ], providers: [InitBootstrapProvider], }; diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts index eebc1be46..79951d8c0 100644 --- a/apps/nestjs-backend/src/cache/types.ts +++ b/apps/nestjs-backend/src/cache/types.ts @@ -22,6 +22,7 @@ export interface ICacheStore { // userId:tableId:windowId [key: `operations:undo:${string}:${string}:${string}`]: IUndoRedoOperation[]; [key: `operations:redo:${string}:${string}:${string}`]: IUndoRedoOperation[]; + [key: `plugin:auth-code:${string}`]: IPluginAuthStore; } export interface IAttachmentSignatureCache { @@ -242,3 +243,7 @@ export type IUndoRedoOperation = | ICreateViewOperation | IDeleteViewOperation | IUpdateViewOperation; +export interface IPluginAuthStore { + baseId: string; + pluginId: string; +} diff --git a/apps/nestjs-backend/src/configs/base.config.ts b/apps/nestjs-backend/src/configs/base.config.ts index a85d1d6e7..91ee8ec93 100644 --- a/apps/nestjs-backend/src/configs/base.config.ts +++ b/apps/nestjs-backend/src/configs/base.config.ts @@ -13,6 +13,7 @@ export const baseConfig = registerAs('base', () => ({ defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20), templateSpaceId: process.env.TEMPLATE_SPACE_ID, recordHistoryDisabled: process.env.RECORD_HISTORY_DISABLED === 'true', + pluginServerPort: process.env.PLUGIN_SERVER_PORT || '3002', })); export const BaseConfig = () => Inject(baseConfig.KEY); diff --git a/apps/nestjs-backend/src/features/access-token/access-token.service.ts b/apps/nestjs-backend/src/features/access-token/access-token.service.ts index cfe16e8fb..54d864baf 100644 --- a/apps/nestjs-backend/src/features/access-token/access-token.service.ts +++ b/apps/nestjs-backend/src/features/access-token/access-token.service.ts @@ -45,15 +45,19 @@ export class AccessTokenService { async validate(splitAccessTokenObj: { accessTokenId: string; sign: string }) { const { accessTokenId, sign } = splitAccessTokenObj; - const accessTokenEntity = await this.prismaService.accessToken.findUniqueOrThrow({ - where: { id: accessTokenId }, - select: { - userId: true, - id: true, - sign: true, - expiredTime: true, - }, - }); + const accessTokenEntity = await this.prismaService.accessToken + .findUniqueOrThrow({ + where: { id: accessTokenId }, + select: { + userId: true, + id: true, + sign: true, + expiredTime: true, + }, + }) + .catch(() => { + throw new UnauthorizedException('token not found'); + }); if (sign !== accessTokenEntity.sign) { throw new UnauthorizedException('sign error'); } diff --git a/apps/nestjs-backend/src/features/attachments/attachments.controller.ts b/apps/nestjs-backend/src/features/attachments/attachments.controller.ts index 730658df3..fc5136f09 100644 --- a/apps/nestjs-backend/src/features/attachments/attachments.controller.ts +++ b/apps/nestjs-backend/src/features/attachments/attachments.controller.ts @@ -66,6 +66,7 @@ export class AttachmentsController { headers['Content-Disposition'] = responseContentDisposition; } } + headers['Cross-Origin-Resource-Policy'] = 'unsafe-none'; res.set(headers); return new StreamableFile(fileStream); } diff --git a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts index 09d2723de..9f4f37a8f 100644 --- a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts +++ b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts @@ -13,6 +13,7 @@ export default abstract class StorageAdapter { case UploadType.Avatar: case UploadType.OAuth: case UploadType.Form: + case UploadType.Plugin: return storageConfig().publicBucket; default: throw new BadRequestException('Invalid upload type'); @@ -31,6 +32,8 @@ export default abstract class StorageAdapter { return 'oauth'; case UploadType.Import: return 'import'; + case UploadType.Plugin: + return 'plugin'; default: throw new BadRequestException('Invalid upload type'); } diff --git a/apps/nestjs-backend/src/features/attachments/plugins/local.ts b/apps/nestjs-backend/src/features/attachments/plugins/local.ts index fdb65d6c0..b7147f640 100644 --- a/apps/nestjs-backend/src/features/attachments/plugins/local.ts +++ b/apps/nestjs-backend/src/features/attachments/plugins/local.ts @@ -149,11 +149,13 @@ export class LocalStorage implements StorageAdapter { }); } - async save(filePath: string, rename: string) { + async save(filePath: string, rename: string, isDelete: boolean = true) { const distPath = resolve(this.storageDir); const newFilePath = resolve(distPath, rename); await fse.copy(filePath, newFilePath); - await fse.remove(filePath); + if (isDelete) { + await fse.remove(filePath); + } return join(this.path, rename); } @@ -239,7 +241,7 @@ export class LocalStorage implements StorageAdapter { _metadata: Record ) { const hash = await FileUtils.getHash(filePath); - await this.save(filePath, join(bucket, path)); + await this.save(filePath, join(bucket, path), false); return { hash, path, diff --git a/apps/nestjs-backend/src/features/auth/permission.service.ts b/apps/nestjs-backend/src/features/auth/permission.service.ts index 2e655433f..e5dedb9c9 100644 --- a/apps/nestjs-backend/src/features/auth/permission.service.ts +++ b/apps/nestjs-backend/src/features/auth/permission.service.ts @@ -81,7 +81,7 @@ export class PermissionService { select: { scopes: true, spaceIds: true, baseIds: true, clientId: true, userId: true }, }); const scopes = JSON.parse(stringifyScopes) as Action[]; - if (clientId) { + if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) { const { spaceIds: spaceIdsByOAuth, baseIds: baseIdsByOAuth } = await this.getOAuthAccessBy(userId); return { diff --git a/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts index e195223bc..f63a64e57 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts @@ -30,7 +30,7 @@ export class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStr const user = await this.userService.getUserById(userId); if (!user) { - throw new UnauthorizedException(); + throw new UnauthorizedException('User not found'); } if (user.deactivatedTime) { throw new UnauthorizedException('Your account has been deactivated by the administrator'); diff --git a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts index 68ed89ee4..90285dca6 100644 --- a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts +++ b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts @@ -1,4 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { IAttachmentCellValue } from '@teable/core'; +import { CellFormat, FieldType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { BaseQueryColumnType, BaseQueryJoinType } from '@teable/openapi'; import type { IBaseQueryJoin, IBaseQuery, IBaseQueryVo, IBaseQueryColumn } from '@teable/openapi'; @@ -14,6 +16,7 @@ import { createFieldInstanceByVo, type IFieldInstance, } from '../../field/model/factory'; +import { RecordService } from '../../record/record.service'; import { QueryAggregation } from './parse/aggregation'; import { QueryFilter } from './parse/filter'; import { QueryGroup } from './parse/group'; @@ -31,7 +34,8 @@ export class BaseQueryService { private readonly fieldService: FieldService, private readonly prismaService: PrismaService, - private readonly cls: ClsService + private readonly cls: ClsService, + private readonly recordService: RecordService ) {} private convertFieldMapToColumn(fieldMap: Record): IBaseQueryColumn[] { @@ -48,23 +52,56 @@ export class BaseQueryService { }); } - private handleBigIntRows(rows: { [key in string]: unknown }[]) { - return rows.map((row) => { - return Object.entries(row).reduce( - (acc, [key, value]) => { + // eslint-disable-next-line sonarjs/cognitive-complexity + private async dbRows2Rows( + rows: Record[], + columns: IBaseQueryColumn[], + cellFormat: CellFormat + ) { + const resRows: Record[] = []; + for (const row of rows) { + const resRow: Record = {}; + for (const field of columns) { + if (!field.fieldSource) { + const value = row[field.column]; + resRow[field.column] = row[field.column]; + // handle bigint if (typeof value === 'bigint') { - acc[key] = Number(value); + resRow[field.column] = Number(value); } else { - acc[key] = value; + resRow[field.column] = value; } - return acc; - }, - {} as { [key in string]: unknown } - ); - }); + continue; + } + const dbCellValue = row[field.column]; + const fieldInstance = createFieldInstanceByVo(field.fieldSource); + const cellValue = fieldInstance.convertDBValue2CellValue(dbCellValue); + + // number no need to convert string + if (typeof cellValue === 'number') { + resRow[field.column] = cellValue; + continue; + } + if (cellValue != null) { + resRow[field.column] = + cellFormat === CellFormat.Text ? fieldInstance.cellValue2String(cellValue) : cellValue; + } + if (fieldInstance.type === FieldType.Attachment) { + resRow[field.column] = await this.recordService.getAttachmentPresignedCellValue( + cellValue as IAttachmentCellValue + ); + } + } + resRows.push(resRow); + } + return resRows; } - async baseQuery(baseId: string, baseQuery: IBaseQuery): Promise { + async baseQuery( + baseId: string, + baseQuery: IBaseQuery, + cellFormat: CellFormat = CellFormat.Json + ): Promise { const { queryBuilder, fieldMap } = await this.parseBaseQuery(baseId, baseQuery, 0); const query = queryBuilder.toQuery(); this.logger.log('baseQuery SQL: ', query); @@ -74,10 +111,11 @@ export class BaseQueryService { this.logger.error(e); throw new BadRequestException(`Query failed: ${query}, ${e.message}`); }); + const columns = this.convertFieldMapToColumn(fieldMap); return { - rows: this.handleBigIntRows(rows), - columns: this.convertFieldMapToColumn(fieldMap), + rows: await this.dbRows2Rows(rows, columns, cellFormat), + columns, }; } diff --git a/apps/nestjs-backend/src/features/base/base.controller.ts b/apps/nestjs-backend/src/features/base/base.controller.ts index 775559c17..f07b6052e 100644 --- a/apps/nestjs-backend/src/features/base/base.controller.ts +++ b/apps/nestjs-backend/src/features/base/base.controller.ts @@ -174,11 +174,12 @@ export class BaseController { } @Get(':baseId/query') + @Permissions('base|query_data') async sqlQuery( @Param('baseId') baseId: string, @Query(new ZodValidationPipe(baseQuerySchemaRo)) query: IBaseQuerySchemaRo ) { - return this.baseQueryService.baseQuery(baseId, query.query); + return this.baseQueryService.baseQuery(baseId, query.query, query.cellFormat); } @Permissions('base|invite_link') diff --git a/apps/nestjs-backend/src/features/base/base.module.ts b/apps/nestjs-backend/src/features/base/base.module.ts index a0176d9eb..0fd714c61 100644 --- a/apps/nestjs-backend/src/features/base/base.module.ts +++ b/apps/nestjs-backend/src/features/base/base.module.ts @@ -3,6 +3,7 @@ import { DbProvider } from '../../db-provider/db.provider'; import { CollaboratorModule } from '../collaborator/collaborator.module'; import { FieldModule } from '../field/field.module'; import { InvitationModule } from '../invitation/invitation.module'; +import { RecordModule } from '../record/record.module'; import { TableOpenApiModule } from '../table/open-api/table-open-api.module'; import { TableModule } from '../table/table.module'; import { BaseDuplicateService } from './base-duplicate.service'; @@ -13,7 +14,14 @@ import { DbConnectionService } from './db-connection.service'; @Module({ controllers: [BaseController], - imports: [CollaboratorModule, FieldModule, TableModule, InvitationModule, TableOpenApiModule], + imports: [ + CollaboratorModule, + FieldModule, + TableModule, + InvitationModule, + TableOpenApiModule, + RecordModule, + ], providers: [DbProvider, BaseService, DbConnectionService, BaseDuplicateService, BaseQueryService], exports: [BaseService, DbConnectionService, BaseDuplicateService], }) diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts new file mode 100644 index 000000000..e5b08e8b9 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts @@ -0,0 +1,19 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { DashboardController } from './dashboard.controller'; + +describe('DashboardController', () => { + let controller: DashboardController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DashboardController], + }).compile(); + + controller = module.get(DashboardController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts new file mode 100644 index 000000000..bd4a2d9f9 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts @@ -0,0 +1,135 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { + createDashboardRoSchema, + dashboardInstallPluginRoSchema, + ICreateDashboardRo, + IRenameDashboardRo, + IUpdateLayoutDashboardRo, + renameDashboardRoSchema, + updateLayoutDashboardRoSchema, + IDashboardInstallPluginRo, + dashboardPluginUpdateStorageRoSchema, + IDashboardPluginUpdateStorageRo, +} from '@teable/openapi'; +import type { + ICreateDashboardVo, + IGetDashboardVo, + IRenameDashboardVo, + IUpdateLayoutDashboardVo, + IGetDashboardListVo, + IDashboardInstallPluginVo, + IDashboardPluginUpdateStorageVo, + IGetDashboardInstallPluginVo, +} from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { DashboardService } from './dashboard.service'; + +@Controller('api/base/:baseId/dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Get() + @Permissions('base|read') + getDashboard(@Param('baseId') baseId: string): Promise { + return this.dashboardService.getDashboard(baseId); + } + + @Get(':id') + @Permissions('base|read') + getDashboardById( + @Param('baseId') baseId: string, + @Param('id') id: string + ): Promise { + return this.dashboardService.getDashboardById(baseId, id); + } + + @Post() + @Permissions('base|update') + createDashboard( + @Param('baseId') baseId: string, + @Body(new ZodValidationPipe(createDashboardRoSchema)) ro: ICreateDashboardRo + ): Promise { + return this.dashboardService.createDashboard(baseId, ro); + } + + @Patch(':id/rename') + @Permissions('base|update') + updateDashboard( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo + ): Promise { + return this.dashboardService.renameDashboard(baseId, id, ro.name); + } + + @Patch(':id/layout') + @Permissions('base|update') + updateLayout( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(updateLayoutDashboardRoSchema)) ro: IUpdateLayoutDashboardRo + ): Promise { + return this.dashboardService.updateLayout(baseId, id, ro.layout); + } + + @Delete(':id') + @Permissions('base|update') + deleteDashboard(@Param('baseId') baseId: string, @Param('id') id: string): Promise { + return this.dashboardService.deleteDashboard(baseId, id); + } + + @Post(':id/plugin') + @Permissions('base|update') + installPlugin( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(dashboardInstallPluginRoSchema)) ro: IDashboardInstallPluginRo + ): Promise { + return this.dashboardService.installPlugin(baseId, id, ro); + } + + @Delete(':id/plugin/:pluginInstallId') + @Permissions('base|update') + removePlugin( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string + ): Promise { + return this.dashboardService.removePlugin(baseId, id, pluginInstallId); + } + + @Patch(':id/plugin/:pluginInstallId/rename') + @Permissions('base|update') + renamePlugin( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string, + @Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo + ): Promise { + return this.dashboardService.renamePlugin(baseId, id, pluginInstallId, ro.name); + } + + @Patch(':id/plugin/:pluginInstallId/update-storage') + @Permissions('base|update') + updatePluginStorage( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string, + @Body(new ZodValidationPipe(dashboardPluginUpdateStorageRoSchema)) + ro: IDashboardPluginUpdateStorageRo + ): Promise { + return this.dashboardService.updatePluginStorage(baseId, id, pluginInstallId, ro.storage); + } + + @Get(':id/plugin/:pluginInstallId') + @Permissions('base|read') + getPluginInstall( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string + ): Promise { + return this.dashboardService.getPluginInstall(baseId, id, pluginInstallId); + } +} diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts new file mode 100644 index 000000000..2582f2088 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + providers: [DashboardService], + controllers: [DashboardController], +}) +export class DashboardModule {} diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts new file mode 100644 index 000000000..bef15c765 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts @@ -0,0 +1,21 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '../../global/global.module'; +import { DashboardModule } from './dashboard.module'; +import { DashboardService } from './dashboard.service'; + +describe('DashboardService', () => { + let service: DashboardService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [GlobalModule, DashboardModule], + }).compile(); + + service = module.get(DashboardService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts new file mode 100644 index 000000000..429cef2b5 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts @@ -0,0 +1,363 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { generateDashboardId, generatePluginInstallId } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { PluginPosition, PluginStatus } from '@teable/openapi'; +import type { + ICreateDashboardRo, + IDashboardInstallPluginRo, + IGetDashboardInstallPluginVo, + IGetDashboardListVo, + IGetDashboardVo, + IUpdateLayoutDashboardRo, +} from '@teable/openapi'; +import type { IDashboardLayout, IDashboardPluginItem } from '@teable/openapi/src/dashboard/types'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; + +@Injectable() +export class DashboardService { + constructor( + private readonly prismaService: PrismaService, + private readonly cls: ClsService + ) {} + + async getDashboard(baseId: string): Promise { + return this.prismaService.dashboard.findMany({ + where: { + baseId, + }, + select: { + id: true, + name: true, + }, + }); + } + + async getDashboardById(baseId: string, id: string): Promise { + const dashboard = await this.prismaService.dashboard + .findFirstOrThrow({ + where: { + id, + baseId, + }, + select: { + id: true, + name: true, + layout: true, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + + const plugins = await this.prismaService.pluginInstall.findMany({ + where: { + positionId: dashboard.id, + position: PluginPosition.Dashboard, + }, + select: { + id: true, + name: true, + pluginId: true, + plugin: { + select: { + url: true, + }, + }, + }, + }); + + return { + ...dashboard, + layout: dashboard.layout ? JSON.parse(dashboard.layout) : undefined, + pluginMap: plugins.reduce( + (acc, plugin) => { + acc[plugin.id] = { + id: plugin.pluginId, + pluginInstallId: plugin.id, + name: plugin.name, + url: plugin.plugin.url ?? undefined, + }; + return acc; + }, + {} as Record + ), + }; + } + + async createDashboard(baseId: string, dashboard: ICreateDashboardRo) { + const userId = this.cls.get('user.id'); + return this.prismaService.dashboard.create({ + data: { + id: generateDashboardId(), + baseId, + name: dashboard.name, + createdBy: userId, + }, + select: { + id: true, + name: true, + }, + }); + } + + async renameDashboard(baseId: string, id: string, name: string) { + return this.prismaService.dashboard + .update({ + where: { + baseId, + id, + }, + data: { + name, + }, + select: { + id: true, + name: true, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + } + + async updateLayout(baseId: string, id: string, layout: IUpdateLayoutDashboardRo['layout']) { + const ro = await this.prismaService.dashboard + .update({ + where: { + baseId, + id, + }, + data: { + layout: JSON.stringify(layout), + }, + select: { + id: true, + name: true, + layout: true, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + return { + ...ro, + layout: ro.layout ? JSON.parse(ro.layout) : undefined, + }; + } + + async deleteDashboard(baseId: string, id: string) { + await this.prismaService.dashboard + .delete({ + where: { + baseId, + id, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + } + + private async validatePluginPublished(_baseId: string, pluginId: string) { + return this.prismaService.plugin + .findFirstOrThrow({ + where: { + id: pluginId, + status: PluginStatus.Published, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + } + + async installPlugin(baseId: string, id: string, ro: IDashboardInstallPluginRo) { + const userId = this.cls.get('user.id'); + await this.validatePluginPublished(baseId, ro.pluginId); + return this.prismaService.$tx(async () => { + const newInstallPlugin = await this.prismaService.txClient().pluginInstall.create({ + data: { + id: generatePluginInstallId(), + baseId, + positionId: id, + position: PluginPosition.Dashboard, + name: ro.name, + pluginId: ro.pluginId, + createdBy: userId, + }, + select: { + id: true, + name: true, + pluginId: true, + }, + }); + const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({ + where: { + id, + baseId, + }, + select: { + layout: true, + }, + }); + const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : []; + layout.push({ + pluginInstallId: newInstallPlugin.id, + x: (layout.length * 2) % 12, + y: Number.MAX_SAFE_INTEGER, // puts it at the bottom + w: 2, + h: 2, + }); + await this.prismaService.txClient().dashboard.update({ + where: { + id, + }, + data: { + layout: JSON.stringify(layout), + }, + }); + return { + id, + pluginId: newInstallPlugin.pluginId, + pluginInstallId: newInstallPlugin.id, + name: ro.name, + }; + }); + } + + private async validateDashboard(baseId: string, dashboardId: string) { + await this.prismaService + .txClient() + .dashboard.findFirstOrThrow({ + where: { + baseId, + id: dashboardId, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + } + + async removePlugin(baseId: string, dashboardId: string, pluginInstallId: string) { + return this.prismaService.$tx(async () => { + await this.prismaService + .txClient() + .pluginInstall.delete({ + where: { + id: pluginInstallId, + baseId, + positionId: dashboardId, + plugin: { + status: PluginStatus.Published, + }, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({ + where: { + id: dashboardId, + baseId, + }, + select: { + layout: true, + }, + }); + const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : []; + const index = layout.findIndex((item) => item.pluginInstallId === pluginInstallId); + if (index !== -1) { + layout.splice(index, 1); + await this.prismaService.txClient().dashboard.update({ + where: { + id: dashboardId, + }, + data: { + layout: JSON.stringify(layout), + }, + }); + } + }); + } + + private async validateAndGetPluginInstall(pluginInstallId: string) { + return this.prismaService + .txClient() + .pluginInstall.findFirstOrThrow({ + where: { + id: pluginInstallId, + plugin: { + status: PluginStatus.Published, + }, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + } + + async renamePlugin(baseId: string, dashboardId: string, pluginInstallId: string, name: string) { + return this.prismaService.$tx(async () => { + await this.validateDashboard(baseId, dashboardId); + const plugin = await this.validateAndGetPluginInstall(pluginInstallId); + await this.prismaService.txClient().pluginInstall.update({ + where: { + id: pluginInstallId, + }, + data: { + name, + }, + }); + return { + id: plugin.pluginId, + pluginInstallId, + name, + }; + }); + } + + async updatePluginStorage( + baseId: string, + dashboardId: string, + pluginInstallId: string, + storage?: Record + ) { + return this.prismaService.$tx(async () => { + await this.validateDashboard(baseId, dashboardId); + await this.validateAndGetPluginInstall(pluginInstallId); + const res = await this.prismaService.txClient().pluginInstall.update({ + where: { + id: pluginInstallId, + }, + data: { + storage: storage ? JSON.stringify(storage) : null, + }, + }); + return { + baseId, + dashboardId, + pluginInstallId: res.id, + storage: res.storage ? JSON.parse(res.storage) : undefined, + }; + }); + } + + async getPluginInstall( + baseId: string, + dashboardId: string, + pluginInstallId: string + ): Promise { + await this.validateDashboard(baseId, dashboardId); + const plugin = await this.validateAndGetPluginInstall(pluginInstallId); + return { + name: plugin.name, + baseId: plugin.baseId, + pluginId: plugin.pluginId, + pluginInstallId: plugin.id, + storage: plugin.storage ? JSON.parse(plugin.storage) : undefined, + }; + } +} diff --git a/apps/nestjs-backend/src/features/next/next.controller.ts b/apps/nestjs-backend/src/features/next/next.controller.ts index 96e8c4f3e..6679091bf 100644 --- a/apps/nestjs-backend/src/features/next/next.controller.ts +++ b/apps/nestjs-backend/src/features/next/next.controller.ts @@ -31,7 +31,6 @@ export class NextController { 'oauth/?*', 'developer/?*', 'public/?*', - 'plugin/?*', ]) public async home(@Req() req: express.Request, @Res() res: express.Response) { await this.nextService.server.getRequestHandler()(req, res); diff --git a/apps/nestjs-backend/src/features/next/next.module.ts b/apps/nestjs-backend/src/features/next/next.module.ts index 510f5fd4d..363cfb927 100644 --- a/apps/nestjs-backend/src/features/next/next.module.ts +++ b/apps/nestjs-backend/src/features/next/next.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { NextController } from './next.controller'; import { NextService } from './next.service'; - +import { NextPluginModule } from './plugin/plugin.module'; @Module({ + imports: [NextPluginModule], providers: [NextService], controllers: [NextController], }) diff --git a/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts new file mode 100644 index 000000000..31f86ccba --- /dev/null +++ b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts @@ -0,0 +1,21 @@ +// proxy.middleware.ts +import type { NestMiddleware } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { RequestHandler } from 'http-proxy-middleware'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { BaseConfig, IBaseConfig } from '../../../configs/base.config'; + +export class PluginProxyMiddleware implements NestMiddleware { + private proxy: RequestHandler; + + constructor(@BaseConfig() private readonly baseConfig: IBaseConfig) { + this.proxy = createProxyMiddleware({ + target: `http://localhost:${baseConfig.pluginServerPort}`, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async use(req: Request, res: Response, next: () => void): Promise { + this.proxy(req, res, next); + } +} diff --git a/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts new file mode 100644 index 000000000..3d46b8273 --- /dev/null +++ b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts @@ -0,0 +1,16 @@ +import type { MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { Module, RequestMethod } from '@nestjs/common'; +import { PluginProxyMiddleware } from './plugin-proxy.middleware'; +@Module({ + providers: [], + imports: [], +}) +export class PluginProxyModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configure(consumer: MiddlewareConsumer): any { + consumer.apply(PluginProxyMiddleware).forRoutes({ + method: RequestMethod.ALL, + path: 'plugin/?*', + }); + } +} diff --git a/apps/nestjs-backend/src/features/next/plugin/plugin.module.ts b/apps/nestjs-backend/src/features/next/plugin/plugin.module.ts new file mode 100644 index 000000000..e0abb3e59 --- /dev/null +++ b/apps/nestjs-backend/src/features/next/plugin/plugin.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PluginProxyModule } from './plugin-proxy.module'; +@Module({ + imports: [PluginProxyModule], + providers: [], + controllers: [], +}) +export class NextPluginModule {} diff --git a/apps/nestjs-backend/src/features/plugin/official/config/chart.ts b/apps/nestjs-backend/src/features/plugin/official/config/chart.ts new file mode 100644 index 000000000..9eb4a6614 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/official/config/chart.ts @@ -0,0 +1,32 @@ +export const chartConfig = { + id: 'plgRuucVT5FikQSIyC9', + name: 'Chart', + description: 'Visualize your records on a bar, line, pie', + detailDesc: ` + If you're looking for a colorful way to get a big-picture overview of a table, try a chart app. + + + + The chart app summarizes a table of records and turns it into an interactive bar, line, pie. Make your chart pop by choosing from a set of colors, or color-code the chart to match your records' associated single select fields. + + + + When you need to drill down into your records, clicking on any bar or point on your chart will bring up the associated record or records. + + + Learn more](https://teable.io)", + + `, + helpUrl: 'https://teable.io', + positions: ['dashboard'], + i18n: { + zh: { + name: '图表', + helpUrl: 'https://teable.cn', + description: '通过柱状图、折线图、饼图可视化您的记录', + detailDesc: + '如果您想通过色彩丰富的方式从大局上了解表格,试试图表应用。\n\n图表应用汇总表格记录,并将其转换为交互式的柱状图、折线图、饼图。通过选择一组颜色让您的图表更引人注目,或根据记录的单选字段为图表添加颜色编码。\n\n当您需要深入了解记录时,点击图表上的任何柱状或点状部分,即可显示相关记录或记录详情。\n\n[了解更多](https://teable.cn)', + }, + }, + logoPath: 'static/plugin/chart.png', +}; diff --git a/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts b/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts new file mode 100644 index 000000000..5bc501611 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts @@ -0,0 +1,165 @@ +import { join, resolve } from 'path'; +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generatePluginUserId, getPluginEmail } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { PluginStatus, UploadType } from '@teable/openapi'; +import { createReadStream } from 'fs-extra'; +import { Knex } from 'knex'; +import { InjectModel } from 'nest-knexjs'; +import sharp from 'sharp'; +import { BaseConfig, IBaseConfig } from '../../../configs/base.config'; +import StorageAdapter from '../../attachments/plugins/adapter'; +import { InjectStorageAdapter } from '../../attachments/plugins/storage'; +import { UserService } from '../../user/user.service'; +import { generateSecret } from '../utils'; +import { chartConfig } from './config/chart'; + +@Injectable() +export class OfficialPluginInitService implements OnModuleInit { + private logger = new Logger(OfficialPluginInitService.name); + + constructor( + private readonly prismaService: PrismaService, + private readonly userService: UserService, + private readonly configService: ConfigService, + @InjectStorageAdapter() readonly storageAdapter: StorageAdapter, + @BaseConfig() private readonly baseConfig: IBaseConfig, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex + ) {} + + // init official plugins + async onModuleInit() { + const officialPlugins = [ + { + ...chartConfig, + secret: this.configService.get('PLUGIN_CHART_SECRET') || this.baseConfig.secretKey, + url: `${this.baseConfig.publicOrigin}/plugin/chart`, + }, + ]; + + try { + await this.prismaService.$tx(async () => { + for (const plugin of officialPlugins) { + this.logger.log(`Creating official plugin: ${plugin.name}`); + await this.createOfficialPlugin(plugin); + } + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.code !== 'P2002') { + throw error; + } + } + } + + async uploadLogo(id: string, filePath: string) { + const fileStream = createReadStream(resolve(process.cwd(), filePath)); + const metaReader = sharp(); + const sharpReader = fileStream.pipe(metaReader); + const { width, height, format = 'png', size = 0 } = await sharpReader.metadata(); + const path = join(StorageAdapter.getDir(UploadType.Plugin), id); + const bucket = StorageAdapter.getBucket(UploadType.Plugin); + const mimetype = `image/${format}`; + const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, filePath, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': mimetype, + }); + await this.prismaService.txClient().attachments.upsert({ + create: { + token: id, + path, + size, + width, + height, + hash, + mimetype, + createdBy: 'system', + }, + update: { + size, + width, + height, + hash, + mimetype, + lastModifiedBy: 'system', + }, + where: { + token: id, + deletedTime: null, + }, + }); + return `/${path}/${id}`; + } + + async createOfficialPlugin(pluginConfig: typeof chartConfig & { secret: string; url: string }) { + const { + id: pluginId, + name, + description, + detailDesc, + logoPath, + i18n, + positions, + helpUrl, + secret, + url, + } = pluginConfig; + + const rows = await this.prismaService.txClient().plugin.count({ where: { id: pluginId } }); + + if (rows > 0) { + const { hashedSecret, maskedSecret } = await generateSecret(secret); + return this.prismaService.txClient().plugin.update({ + where: { + id: pluginId, + }, + data: { + secret: hashedSecret, + maskedSecret, + }, + }); + } + // upload logo + const logo = await this.uploadLogo(pluginId, logoPath); + const pluginUserId = generatePluginUserId(); + const user = await this.userService.createSystemUser({ + id: pluginUserId, + name, + email: getPluginEmail(pluginId), + }); + const { hashedSecret, maskedSecret } = await generateSecret(secret); + return this.prismaService.txClient().plugin.create({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + createdTime: true, + }, + data: { + id: pluginId, + name, + description, + detailDesc, + positions: JSON.stringify(positions), + helpUrl, + url, + logo, + status: PluginStatus.Published, + i18n: JSON.stringify(i18n), + secret: hashedSecret, + maskedSecret, + pluginUser: user.id, + createdBy: 'system', + }, + }); + } +} diff --git a/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts new file mode 100644 index 000000000..db7937a56 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts @@ -0,0 +1,191 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { getRandomString } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { + PluginStatus, + type IPluginGetTokenRo, + type IPluginGetTokenVo, + type IPluginRefreshTokenRo, + type IPluginRefreshTokenVo, +} from '@teable/openapi'; +import { CacheService } from '../../cache/cache.service'; +import { second } from '../../utils/second'; +import { AccessTokenService } from '../access-token/access-token.service'; +import { validateSecret } from './utils'; + +interface IRefreshPayload { + pluginId: string; + secret: string; + accessTokenId: string; +} + +@Injectable() +export class PluginAuthService { + accessTokenExpireIn = second('10m'); + refreshTokenExpireIn = second('30d'); + + constructor( + private readonly prismaService: PrismaService, + private readonly cacheService: CacheService, + private readonly accessTokenService: AccessTokenService, + private readonly jwtService: JwtService + ) {} + + private generateAccessToken({ + userId, + scopes, + clientId, + name, + baseId, + }: { + userId: string; + scopes: string[]; + clientId: string; + name: string; + baseId: string; + }) { + return this.accessTokenService.createAccessToken({ + clientId, + name: `plugin:${name}`, + scopes, + userId, + baseIds: [baseId], + // 10 minutes + expiredTime: new Date(Date.now() + this.accessTokenExpireIn * 1000).toISOString(), + }); + } + + private async generateRefreshToken({ pluginId, secret, accessTokenId }: IRefreshPayload) { + return this.jwtService.signAsync( + { + secret, + accessTokenId, + pluginId, + }, + { expiresIn: this.refreshTokenExpireIn } + ); + } + + private async validateSecret(secret: string, pluginId: string) { + const plugin = await this.prismaService.plugin + .findFirstOrThrow({ + where: { id: pluginId, status: PluginStatus.Published }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + if (!plugin.pluginUser) { + throw new BadRequestException('Plugin user not found'); + } + const checkSecret = await validateSecret(secret, plugin.secret); + if (!checkSecret) { + throw new BadRequestException('Invalid secret'); + } + return { + ...plugin, + pluginUser: plugin.pluginUser, + }; + } + + async token(pluginId: string, ro: IPluginGetTokenRo): Promise { + const { secret, scopes, baseId } = ro; + const plugin = await this.validateSecret(secret, pluginId); + + const accessToken = await this.generateAccessToken({ + userId: plugin.pluginUser, + scopes, + baseId, + clientId: pluginId, + name: plugin.name, + }); + + const refreshToken = await this.generateRefreshToken({ + pluginId, + secret, + accessTokenId: accessToken.id, + }); + + return { + accessToken: accessToken.token, + refreshToken, + scopes, + expiresIn: this.accessTokenExpireIn, + refreshExpiresIn: this.refreshTokenExpireIn, + }; + } + + async refreshToken(pluginId: string, ro: IPluginRefreshTokenRo): Promise { + const { secret, refreshToken } = ro; + const plugin = await this.validateSecret(secret, pluginId); + const payload = await this.jwtService.verifyAsync(refreshToken).catch(() => { + // eslint-disable-next-line sonarjs/no-duplicate-string + throw new BadRequestException('Invalid refresh token'); + }); + + if ( + payload.pluginId !== pluginId || + payload.secret !== secret || + payload.accessTokenId === undefined + ) { + throw new BadRequestException('Invalid refresh token'); + } + return this.prismaService.$tx(async (prisma) => { + const oldAccessToken = await prisma.accessToken + .findFirstOrThrow({ + where: { id: payload.accessTokenId }, + }) + .catch(() => { + throw new BadRequestException('Invalid refresh token'); + }); + + await prisma.accessToken.delete({ + where: { id: payload.accessTokenId, userId: plugin.pluginUser }, + }); + + const baseId = oldAccessToken.baseIds ? JSON.parse(oldAccessToken.baseIds)[0] : ''; + const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : []; + if (!baseId) { + throw new InternalServerErrorException('Anomalous token with no baseId'); + } + + const accessToken = await this.generateAccessToken({ + userId: plugin.pluginUser, + scopes, + baseId, + clientId: pluginId, + name: plugin.name, + }); + + const refreshToken = await this.generateRefreshToken({ + pluginId, + secret, + accessTokenId: accessToken.id, + }); + return { + accessToken: accessToken.token, + refreshToken, + scopes, + expiresIn: this.accessTokenExpireIn, + refreshExpiresIn: this.refreshTokenExpireIn, + }; + }); + } + + async authCode(pluginId: string, baseId: string) { + const count = await this.prismaService.pluginInstall.count({ + where: { pluginId, baseId }, + }); + if (count === 0) { + throw new NotFoundException('Plugin not installed'); + } + const authCode = getRandomString(16); + await this.cacheService.set(`plugin:auth-code:${authCode}`, { baseId, pluginId }, second('5m')); + return authCode; + } +} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts b/apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts new file mode 100644 index 000000000..ddc498f8e --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts @@ -0,0 +1,19 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { PluginController } from './plugin.controller'; + +describe('PluginController', () => { + let controller: PluginController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PluginController], + }).compile(); + + controller = module.get(PluginController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/plugin/plugin.controller.ts b/apps/nestjs-backend/src/features/plugin/plugin.controller.ts new file mode 100644 index 000000000..985c65e1b --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.controller.ts @@ -0,0 +1,109 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import type { + ICreatePluginVo, + IGetPluginCenterListVo, + IGetPluginsVo, + IGetPluginVo, + IPluginGetTokenVo, + IPluginRefreshTokenVo, + IPluginRegenerateSecretVo, + IUpdatePluginVo, +} from '@teable/openapi'; +import { + createPluginRoSchema, + ICreatePluginRo, + updatePluginRoSchema, + IUpdatePluginRo, + getPluginCenterListRoSchema, + IGetPluginCenterListRo, + pluginGetTokenRoSchema, + IPluginGetTokenRo, + pluginRefreshTokenRoSchema, + IPluginRefreshTokenRo, +} from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { ResourceMeta } from '../auth/decorators/resource_meta.decorator'; +import { PluginAuthService } from './plugin-auth.service'; +import { PluginService } from './plugin.service'; + +@Controller('api/plugin') +export class PluginController { + constructor( + private readonly pluginService: PluginService, + private readonly pluginAuthService: PluginAuthService + ) {} + + @Post() + createPlugin( + @Body(new ZodValidationPipe(createPluginRoSchema)) data: ICreatePluginRo + ): Promise { + return this.pluginService.createPlugin(data); + } + + @Get() + getPlugins(): Promise { + return this.pluginService.getPlugins(); + } + + @Get(':pluginId') + getPlugin(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.getPlugin(pluginId); + } + + @Post(':pluginId/regenerate-secret') + regenerateSecret(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.regenerateSecret(pluginId); + } + + @Put(':pluginId') + updatePlugin( + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(updatePluginRoSchema)) ro: IUpdatePluginRo + ): Promise { + return this.pluginService.updatePlugin(pluginId, ro); + } + + @Delete(':pluginId') + deletePlugin(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.delete(pluginId); + } + + @Get('center/list') + getPluginCenterList( + @Query(new ZodValidationPipe(getPluginCenterListRoSchema)) ro: IGetPluginCenterListRo + ): Promise { + return this.pluginService.getPluginCenterList(ro.positions); + } + + @Patch(':pluginId/submit') + submitPlugin(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.submitPlugin(pluginId); + } + + @Post(':pluginId/token') + @Public() + accessToken( + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(pluginGetTokenRoSchema)) ro: IPluginGetTokenRo + ): Promise { + return this.pluginAuthService.token(pluginId, ro); + } + + @Post(':pluginId/refreshToken') + @Public() + refreshToken( + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(pluginRefreshTokenRoSchema)) ro: IPluginRefreshTokenRo + ): Promise { + return this.pluginAuthService.refreshToken(pluginId, ro); + } + + @Post(':pluginId/authCode') + @Permissions('base|read') + @ResourceMeta('baseId', 'body') + authCode(@Param('pluginId') pluginId: string, @Body('baseId') baseId: string): Promise { + return this.pluginAuthService.authCode(pluginId, baseId); + } +} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.module.ts b/apps/nestjs-backend/src/features/plugin/plugin.module.ts new file mode 100644 index 000000000..a3f03f10f --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { authConfig, type IAuthConfig } from '../../configs/auth.config'; +import { AccessTokenModule } from '../access-token/access-token.module'; +import { StorageModule } from '../attachments/plugins/storage.module'; +import { UserModule } from '../user/user.module'; +import { OfficialPluginInitService } from './official/official-plugin-init.service'; +import { PluginAuthService } from './plugin-auth.service'; +import { PluginController } from './plugin.controller'; +import { PluginService } from './plugin.service'; + +@Module({ + imports: [ + UserModule, + AccessTokenModule, + StorageModule, + JwtModule.registerAsync({ + useFactory: (config: IAuthConfig) => ({ + secret: config.jwt.secret, + signOptions: { + expiresIn: config.jwt.expiresIn, + }, + }), + inject: [authConfig.KEY], + }), + ], + providers: [PluginService, PluginAuthService, OfficialPluginInitService], + controllers: [PluginController], +}) +export class PluginModule {} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts b/apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts new file mode 100644 index 000000000..e76833556 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts @@ -0,0 +1,21 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '../../global/global.module'; +import { PluginModule } from './plugin.module'; +import { PluginService } from './plugin.service'; + +describe('PluginService', () => { + let service: PluginService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [GlobalModule, PluginModule], + }).compile(); + + service = module.get(PluginService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/plugin/plugin.service.ts b/apps/nestjs-backend/src/features/plugin/plugin.service.ts new file mode 100644 index 000000000..9d9486a78 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.service.ts @@ -0,0 +1,361 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + generatePluginId, + generatePluginUserId, + getPluginEmail, + nullsToUndefined, +} from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { UploadType, PluginStatus } from '@teable/openapi'; +import type { + IGetPluginCenterListVo, + ICreatePluginRo, + ICreatePluginVo, + IGetPluginsVo, + IGetPluginVo, + IPluginI18n, + IPluginRegenerateSecretVo, + IUpdatePluginRo, + IUpdatePluginVo, + PluginPosition, +} from '@teable/openapi'; +import { omit } from 'lodash'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import StorageAdapter from '../attachments/plugins/adapter'; +import { getFullStorageUrl } from '../attachments/plugins/utils'; +import { UserService } from '../user/user.service'; +import { generateSecret } from './utils'; + +@Injectable() +export class PluginService { + constructor( + private readonly prismaService: PrismaService, + private readonly cls: ClsService, + private readonly userService: UserService + ) {} + + private logoToVoValue(logo: string) { + return getFullStorageUrl(StorageAdapter.getBucket(UploadType.Plugin), logo); + } + + private convertToVo< + T extends { + positions: string; + i18n?: string | null; + status: string; + logo: string; + createdTime?: Date | null; + lastModifiedTime?: Date | null; + }, + >(ro: T) { + return nullsToUndefined({ + ...ro, + logo: this.logoToVoValue(ro.logo), + status: ro.status as PluginStatus, + positions: JSON.parse(ro.positions) as PluginPosition[], + i18n: ro.i18n ? (JSON.parse(ro.i18n) as IPluginI18n) : undefined, + createdTime: ro.createdTime?.toISOString(), + lastModifiedTime: ro.lastModifiedTime?.toISOString(), + }); + } + + private async getUserMap(userIds: string[]) { + const users = await this.prismaService.txClient().user.findMany({ + where: { id: { in: userIds }, deletedTime: null }, + select: { + id: true, + name: true, + email: true, + avatar: true, + }, + }); + const systemUser = userIds.find((id) => id === 'system') + ? { + id: 'system', + name: 'Teable', + email: 'support@teable.io', + avatar: undefined, + } + : undefined; + + const userMap = users.reduce( + (acc, user) => { + if (user.id === 'system') { + acc[user.id] = { + id: user.id, + name: 'Teable', + email: 'support@teable.io', + avatar: undefined, + }; + return acc; + } + acc[user.id] = { + ...user, + avatar: user.avatar + ? getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), user.avatar) + : undefined, + }; + return acc; + }, + {} as Record + ); + + return systemUser + ? { + ...userMap, + system: systemUser, + } + : userMap; + } + + async createPlugin(createPluginRo: ICreatePluginRo): Promise { + const userId = this.cls.get('user.id'); + const { name, description, detailDesc, helpUrl, logo, i18n, positions, url } = createPluginRo; + const { secret, hashedSecret, maskedSecret } = await generateSecret(); + const res = await this.prismaService.$tx(async (prisma) => { + const pluginId = generatePluginId(); + const pluginUserId = generatePluginUserId(); + const user = await this.userService.createSystemUser({ + id: pluginUserId, + name, + email: getPluginEmail(pluginId), + }); + const plugin = await prisma.plugin.create({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + createdTime: true, + }, + data: { + id: pluginId, + name, + description, + detailDesc, + positions: JSON.stringify(positions), + helpUrl, + url, + logo, + status: PluginStatus.Developing, + i18n: JSON.stringify(i18n), + secret: hashedSecret, + maskedSecret, + pluginUser: user.id, + createdBy: userId, + }, + }); + return { + ...plugin, + secret, + pluginUser: { + id: user.id, + name: user.name, + email: user.email, + avatar: user.avatar + ? getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), user.avatar) + : undefined, + }, + }; + }); + return this.convertToVo(res); + } + + async updatePlugin(id: string, updatePluginRo: IUpdatePluginRo): Promise { + const userId = this.cls.get('user.id'); + const isAdmin = this.cls.get('user.isAdmin'); + const { name, description, detailDesc, helpUrl, logo, i18n, positions, url } = updatePluginRo; + const res = await this.prismaService.$tx(async (prisma) => { + const res = await prisma.plugin + .update({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + pluginUser: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId }, + data: { + name, + description, + detailDesc, + positions: JSON.stringify(positions), + helpUrl, + url, + logo, + i18n: JSON.stringify(i18n), + lastModifiedBy: userId, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + + if (name && res.pluginUser) { + await this.userService.updateUserName(res.pluginUser, name); + } + return res; + }); + const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {}; + return this.convertToVo({ + ...res, + pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined, + }); + } + + async getPlugin(id: string): Promise { + const userId = this.cls.get('user.id'); + const isAdmin = this.cls.get('user.isAdmin'); + const res = await this.prismaService.plugin + .findUniqueOrThrow({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + maskedSecret: true, + pluginUser: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {}; + return this.convertToVo({ + ...omit(res, 'maskedSecret'), + secret: res.maskedSecret, + pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined, + }); + } + + async getPlugins(): Promise { + const userId = this.cls.get('user.id'); + const isAdmin = this.cls.get('user.isAdmin'); + + const res = await this.prismaService.plugin.findMany({ + where: { createdBy: isAdmin ? { in: ['system', userId] } : userId }, + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + pluginUser: true, + createdTime: true, + lastModifiedTime: true, + }, + }); + const userIds = res.map((r) => r.pluginUser).filter((r) => r !== null) as string[]; + const userMap = await this.getUserMap(userIds); + return res.map((r) => + this.convertToVo({ + ...r, + pluginUser: r.pluginUser ? userMap[r.pluginUser] : undefined, + }) + ); + } + + async delete(id: string) { + await this.prismaService.$tx(async (prisma) => { + const res = await prisma.plugin.delete({ where: { id } }); + if (res.pluginUser) { + await prisma.user.delete({ where: { id: res.pluginUser } }); + } + }); + } + + async regenerateSecret(id: string): Promise { + const { secret, hashedSecret, maskedSecret } = await generateSecret(); + await this.prismaService.plugin.update({ + select: { + id: true, + secret: true, + }, + where: { id }, + data: { + secret: hashedSecret, + maskedSecret, + }, + }); + return { secret, id }; + } + + async getPluginCenterList(positions?: PluginPosition[]): Promise { + const res = await this.prismaService.plugin.findMany({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + logo: true, + helpUrl: true, + i18n: true, + createdTime: true, + lastModifiedTime: true, + createdBy: true, + }, + where: { + status: PluginStatus.Published, + ...(positions?.length + ? { + OR: positions.map((position) => ({ positions: { contains: position } })), + } + : {}), + }, + }); + const userIds = res.map((r) => r.createdBy); + const userMap = await this.getUserMap(userIds); + return res.map((r) => + nullsToUndefined({ + ...r, + logo: this.logoToVoValue(r.logo), + i18n: r.i18n ? (JSON.parse(r.i18n) as IPluginI18n) : undefined, + createdBy: userMap[r.createdBy], + createdTime: r.createdTime?.toISOString(), + lastModifiedTime: r.lastModifiedTime?.toISOString(), + }) + ); + } + + async submitPlugin(id: string) { + const userId = this.cls.get('user.id'); + await this.prismaService.plugin.update({ + where: { id, createdBy: userId }, + data: { status: PluginStatus.Reviewing }, + }); + } +} diff --git a/apps/nestjs-backend/src/features/plugin/utils.ts b/apps/nestjs-backend/src/features/plugin/utils.ts new file mode 100644 index 000000000..087bba828 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/utils.ts @@ -0,0 +1,15 @@ +import { getRandomString } from '@teable/core'; +import * as bcrypt from 'bcrypt'; + +export const generateSecret = async (_secret?: string) => { + const secret = _secret ?? getRandomString(40).toLocaleLowerCase(); + const hashedSecret = await bcrypt.hash(secret, 10); + + const sensitivePart = secret.slice(0, secret.length - 10); + const maskedSecret = secret.slice(0).replace(sensitivePart, '*'.repeat(sensitivePart.length)); + return { secret, hashedSecret, maskedSecret }; +}; + +export const validateSecret = async (secret: string, hashedSecret: string) => { + return bcrypt.compare(secret, hashedSecret); +}; diff --git a/apps/nestjs-backend/src/features/setting/admin.controller.ts b/apps/nestjs-backend/src/features/setting/admin.controller.ts new file mode 100644 index 000000000..ef184741c --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/admin.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Param, Patch, UseGuards } from '@nestjs/common'; +import { AdminGuard } from './admin.guard'; +import { AdminService } from './admin.service'; + +@Controller('api/admin') +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @UseGuards(AdminGuard) + @Patch('/plugin/:pluginId/publish') + async publishPlugin(@Param('pluginId') pluginId: string): Promise { + await this.adminService.publishPlugin(pluginId); + } +} diff --git a/apps/nestjs-backend/src/features/setting/admin.service.ts b/apps/nestjs-backend/src/features/setting/admin.service.ts new file mode 100644 index 000000000..9a3a16e0e --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/admin.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@teable/db-main-prisma'; +import { PluginStatus } from '@teable/openapi'; + +@Injectable() +export class AdminService { + constructor(private readonly prismaService: PrismaService) {} + + async publishPlugin(pluginId: string) { + return this.prismaService.plugin.update({ + where: { id: pluginId, status: PluginStatus.Reviewing }, + data: { status: PluginStatus.Published }, + }); + } +} diff --git a/apps/nestjs-backend/src/features/setting/setting.module.ts b/apps/nestjs-backend/src/features/setting/setting.module.ts index 0df993dca..2d451ddf1 100644 --- a/apps/nestjs-backend/src/features/setting/setting.module.ts +++ b/apps/nestjs-backend/src/features/setting/setting.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; import { AdminGuard } from './admin.guard'; +import { AdminService } from './admin.service'; import { SettingController } from './setting.controller'; import { SettingService } from './setting.service'; @Module({ - controllers: [SettingController], + controllers: [SettingController, AdminController], exports: [SettingService], - providers: [SettingService, AdminGuard], + providers: [SettingService, AdminGuard, AdminService], }) export class SettingModule {} diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index b93d778f0..ce0955054 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -118,7 +118,9 @@ export class UserService { notifyMeta: JSON.stringify(defaultNotifyMeta), }; - const userTotalCount = await this.prismaService.txClient().user.count(); + const userTotalCount = await this.prismaService.txClient().user.count({ + where: { isSystem: null }, + }); const isAdmin = !this.baseConfig.isCloud && userTotalCount === 0; @@ -345,4 +347,31 @@ export class UserService { }; }); } + + async createSystemUser({ + id = generateUserId(), + email, + name, + avatar, + }: { + id?: string; + email: string; + name: string; + avatar?: string; + }) { + return this.prismaService.$tx(async () => { + if (!avatar) { + avatar = await this.generateDefaultAvatar(id); + } + return this.prismaService.txClient().user.create({ + data: { + id, + email, + name, + avatar, + isSystem: true, + }, + }); + }); + } } diff --git a/apps/nestjs-backend/static/plugin/chart.png b/apps/nestjs-backend/static/plugin/chart.png new file mode 100644 index 000000000..239d5f0b1 Binary files /dev/null and b/apps/nestjs-backend/static/plugin/chart.png differ diff --git a/apps/nestjs-backend/test/base-query.e2e-spec.ts b/apps/nestjs-backend/test/base-query.e2e-spec.ts index 7183de1a8..fbaa3e20c 100644 --- a/apps/nestjs-backend/test/base-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-query.e2e-spec.ts @@ -531,8 +531,6 @@ describe('BaseSqlQuery e2e', () => { { [`${table1.fields[0].id}_${table1.fields[0].name}`]: 'Charlie', [`${table1.fields[1].id}_${table1.fields[1].name}`]: 40, - [`${table2.fields[0].id}_${table2.fields[0].name}`]: null, - [`${table2.fields[1].id}_${table2.fields[1].name}`]: null, }, ]); }); diff --git a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts index a31d90664..e7b28ca22 100644 --- a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts @@ -21,19 +21,22 @@ import { EventEmitterService } from '../src/event-emitter/event-emitter.service' import { Events } from '../src/event-emitter/events'; import { createNewUserAxios } from './utils/axios-instance/new-user'; import { createAwaitWithEvent } from './utils/event-promise'; -import { createField, createTable, initApp } from './utils/init-app'; +import { createBase, createField, createTable, deleteBase, initApp } from './utils/init-app'; describe('Computed user field (e2e)', () => { let app: INestApplication; - const baseId = globalThis.testConfig.baseId; + const spaceId = globalThis.testConfig.spaceId; const userName = globalThis.testConfig.userName; - + let baseId: string; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; + const base = await createBase({ name: 'base1', spaceId }); + baseId = base.id; }); afterAll(async () => { + await deleteBase(baseId); await app.close(); }); diff --git a/apps/nestjs-backend/test/dashboard.e2e-spec.ts b/apps/nestjs-backend/test/dashboard.e2e-spec.ts new file mode 100644 index 000000000..ca15168d4 --- /dev/null +++ b/apps/nestjs-backend/test/dashboard.e2e-spec.ts @@ -0,0 +1,157 @@ +import type { INestApplication } from '@nestjs/common'; +import { + createDashboard, + createDashboardVoSchema, + createPlugin, + dashboardInstallPluginVoSchema, + deleteDashboard, + deletePlugin, + getDashboard, + getDashboardVoSchema, + installPlugin, + PluginPosition, + publishPlugin, + removePlugin, + renameDashboard, + renameDashboardVoSchema, + renamePlugin, + submitPlugin, + updateLayoutDashboard, +} from '@teable/openapi'; +import { getError } from './utils/get-error'; +import { initApp } from './utils/init-app'; + +const dashboardRo = { + name: 'dashboard', +}; + +describe('DashboardController', () => { + let app: INestApplication; + let dashboardId: string; + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + beforeEach(async () => { + const res = await createDashboard(baseId, dashboardRo); + dashboardId = res.data.id; + }); + + afterEach(async () => { + await deleteDashboard(baseId, dashboardId); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/api/dashboard (POST)', async () => { + const res = await createDashboard(baseId, dashboardRo); + expect(createDashboardVoSchema.strict().safeParse(res.data).success).toBe(true); + expect(res.status).toBe(201); + await deleteDashboard(baseId, res.data.id); + }); + + it('/api/dashboard/:id (GET)', async () => { + const getRes = await getDashboard(baseId, dashboardId); + expect(getDashboardVoSchema.strict().safeParse(getRes.data).success).toBe(true); + expect(getRes.data.id).toBe(dashboardId); + }); + + it('/api/dashboard/:id (DELETE)', async () => { + const res = await createDashboard(baseId, dashboardRo); + await deleteDashboard(baseId, res.data.id); + const error = await getError(() => getDashboard(baseId, res.data.id)); + expect(error?.status).toBe(404); + }); + + it('/api/dashboard/:id/rename (PATCH)', async () => { + const res = await createDashboard(baseId, dashboardRo); + const newName = 'new-dashboard'; + const renameRes = await renameDashboard(baseId, res.data.id, newName); + expect(renameRes.data.name).toBe(newName); + await deleteDashboard(baseId, res.data.id); + }); + + it('/api/dashboard/:id/layout (PATCH)', async () => { + const res = await createDashboard(baseId, dashboardRo); + const layout = [{ pluginInstallId: 'plugin-install-id', x: 0, y: 0, w: 1, h: 1 }]; + const updateRes = await updateLayoutDashboard(baseId, res.data.id, layout); + expect(updateRes.data.layout).toEqual(layout); + await deleteDashboard(baseId, res.data.id); + }); + + describe('plugin', () => { + let pluginId: string; + beforeEach(async () => { + const res = await createPlugin({ + name: 'plugin', + logo: 'https://logo.com', + positions: [PluginPosition.Dashboard], + }); + pluginId = res.data.id; + await submitPlugin(pluginId); + await publishPlugin(pluginId); + }); + + afterEach(async () => { + await deletePlugin(pluginId); + }); + + it('/api/dashboard/:id/plugin (POST)', async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'plugin1111', + pluginId, + }); + const dashboard = await getDashboard(baseId, dashboardId); + expect(getDashboardVoSchema.safeParse(dashboard.data).success).toBe(true); + expect(installRes.data.name).toBe('plugin1111'); + expect(dashboardInstallPluginVoSchema.safeParse(installRes.data).success).toBe(true); + }); + + it('/api/dashboard/:id/plugin (POST) - plugin not found', async () => { + const res = await createPlugin({ + name: 'plugin-no', + logo: 'https://logo.com', + positions: [PluginPosition.Dashboard], + }); + const error = await getError(() => + installPlugin(baseId, dashboardId, { + name: 'dddd', + pluginId: res.data.id, + }) + ); + await deletePlugin(res.data.id); + expect(error?.status).toBe(404); + }); + + it('/api/dashboard/:id/plugin/:pluginInstallId/rename (PATCH)', async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'plugin1111', + pluginId, + }); + const newName = 'new-plugin'; + const renameRes = await renamePlugin( + baseId, + dashboardId, + installRes.data.pluginInstallId, + newName + ); + expect(renameDashboardVoSchema.safeParse(renameRes.data).success).toBe(true); + expect(renameRes.data.name).toBe(newName); + }); + + it('/api/dashboard/:id/plugin/:pluginInstallId (DELETE)', async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'plugin1111', + pluginId, + }); + await removePlugin(baseId, dashboardId, installRes.data.pluginInstallId); + const dashboard = await getDashboard(baseId, dashboardId); + expect(dashboard?.data?.pluginMap?.[pluginId]).toBeUndefined(); + }); + }); +}); diff --git a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts index 612651d47..ea6f66860 100644 --- a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts +++ b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; -import { DriverClient, HttpError } from '@teable/core'; +import { HttpError } from '@teable/core'; import { GET_TABLE_LIST, generateOAuthSecret, @@ -195,52 +195,49 @@ describe('OpenAPI OAuthController (e2e)', () => { expect(error?.message).toBe('Invalid user'); }); - it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)( - '/api/oauth/access_token (POST)', - async () => { - const { transactionID } = await gerAuthorize(axios, oauth); + it('/api/oauth/access_token (POST)', async () => { + const { transactionID } = await gerAuthorize(axios, oauth); - const res = await decision(axios, transactionID!); + const res = await decision(axios, transactionID!); - const url = new URL(res.headers.location); - const code = url.searchParams.get('code'); - const secret = await generateOAuthSecret(oauth.clientId); + const url = new URL(res.headers.location); + const code = url.searchParams.get('code'); + const secret = await generateOAuthSecret(oauth.clientId); - const tokenRes = await anonymousAxios.post( - `/oauth/access_token`, - { - grant_type: 'authorization_code', - code, - client_id: oauth.clientId, - client_secret: secret.data.secret, - redirect_uri: oauth.redirectUris[0], - }, - { - maxRedirects: 0, - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ); - expect(tokenRes.status).toBe(201); - expect(tokenRes.data).toEqual({ - token_type: 'Bearer', - scopes: oauth.scopes, - access_token: expect.any(String), - refresh_token: expect.any(String), - expires_in: expect.any(Number), - refresh_expires_in: expect.any(Number), - }); - - const userInfo = await anonymousAxios.get(`/auth/user`, { + const tokenRes = await anonymousAxios.post( + `/oauth/access_token`, + { + grant_type: 'authorization_code', + code, + client_id: oauth.clientId, + client_secret: secret.data.secret, + redirect_uri: oauth.redirectUris[0], + }, + { + maxRedirects: 0, headers: { - Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/x-www-form-urlencoded', }, - }); - expect(userInfo.data.email).toEqual(globalThis.testConfig.email); - } - ); + } + ); + expect(tokenRes.status).toBe(201); + expect(tokenRes.data).toEqual({ + token_type: 'Bearer', + scopes: oauth.scopes, + access_token: expect.any(String), + refresh_token: expect.any(String), + expires_in: expect.any(Number), + refresh_expires_in: expect.any(Number), + }); + + const userInfo = await anonymousAxios.get(`/auth/user`, { + headers: { + Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`, + }, + }); + expect(userInfo.data.email).toEqual(globalThis.testConfig.email); + }); it('/api/oauth/access_token (POST) - has decision', async () => { const { transactionID } = await gerAuthorize(axios, oauth); diff --git a/apps/nestjs-backend/test/plugin.e2e-spec.ts b/apps/nestjs-backend/test/plugin.e2e-spec.ts new file mode 100644 index 000000000..b210afb0a --- /dev/null +++ b/apps/nestjs-backend/test/plugin.e2e-spec.ts @@ -0,0 +1,146 @@ +import type { INestApplication } from '@nestjs/common'; +import { + createPlugin, + createPluginVoSchema, + deletePlugin, + getPlugin, + getPluginCenterList, + getPluginCenterListVoSchema, + getPlugins, + getPluginsVoSchema, + getPluginVoSchema, + PluginPosition, + PluginStatus, + publishPlugin, + submitPlugin, + updatePlugin, +} from '@teable/openapi'; +import { getError } from './utils/get-error'; +import { initApp } from './utils/init-app'; + +const mockPlugin = { + name: 'plugin', + logo: '/plugin/xxxxxxx', + description: 'desc', + detailDesc: 'detail', + helpUrl: 'https://help.com', + positions: [PluginPosition.Dashboard], + i18n: { + en: { + name: 'plugin', + description: 'desc', + detailDesc: 'detail', + }, + }, +}; +describe('PluginController', () => { + let app: INestApplication; + let pluginId: string; + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + beforeEach(async () => { + const res = await createPlugin(mockPlugin); + pluginId = res.data.id; + }); + + afterEach(async () => { + await deletePlugin(pluginId); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/api/plugin (POST)', async () => { + const res = await createPlugin(mockPlugin); + expect(createPluginVoSchema.strict().safeParse(res.data).success).toBe(true); + expect(res.data.status).toBe(PluginStatus.Developing); + expect(res.data.pluginUser).not.toBeUndefined(); + await deletePlugin(res.data.id); + }); + + it('/api/plugin/{pluginId} (GET)', async () => { + const getRes = await getPlugin(pluginId); + expect(getPluginVoSchema.strict().safeParse(getRes.data).success).toBe(true); + expect(getRes.data.status).toBe(PluginStatus.Developing); + expect(getRes.data.pluginUser).not.toBeUndefined(); + expect(getRes.data.pluginUser?.name).toEqual('plugin'); + }); + + it('/api/plugin/{pluginId} (GET) - 404', async () => { + const error = await getError(() => getPlugin('invalid-id')); + expect(error?.status).toBe(404); + }); + + it('/api/plugin (GET)', async () => { + const getRes = await getPlugins(); + expect(getPluginsVoSchema.safeParse(getRes.data).success).toBe(true); + expect(getRes.data).toHaveLength(2); + }); + + it('/api/plugin/{pluginId} (DELETE)', async () => { + const res = await createPlugin(mockPlugin); + await deletePlugin(res.data.id); + const error = await getError(() => getPlugin(res.data.id)); + expect(error?.status).toBe(404); + }); + + it('/api/plugin/{pluginId} (PUT)', async () => { + const res = await createPlugin(mockPlugin); + const updatePluginRo = { + name: 'updated', + description: 'updated', + detailDesc: 'updated', + helpUrl: 'https://updated.com', + logo: '/plugin/updated', + positions: [PluginPosition.Dashboard], + i18n: { + en: { + name: 'updated', + description: 'updated', + detailDesc: 'updated', + }, + }, + }; + const putRes = await updatePlugin(res.data.id, updatePluginRo); + await deletePlugin(res.data.id); + expect(putRes.data.name).toBe(updatePluginRo.name); + expect(putRes.data.description).toBe(updatePluginRo.description); + expect(putRes.data.detailDesc).toBe(updatePluginRo.detailDesc); + expect(putRes.data.helpUrl).toBe(updatePluginRo.helpUrl); + expect(putRes.data.logo).toEqual(expect.stringContaining(updatePluginRo.logo)); + expect(putRes.data.i18n).toEqual(updatePluginRo.i18n); + }); + + it('/api/plugin/{pluginId}/submit (POST)', async () => { + const res = await createPlugin(mockPlugin); + const submitRes = await submitPlugin(res.data.id); + await deletePlugin(res.data.id); + expect(submitRes.status).toBe(200); + }); + + it('/api/admin/plugin/{pluginId}/publish (PATCH)', async () => { + const res = await createPlugin(mockPlugin); + await submitPlugin(res.data.id); + await publishPlugin(res.data.id); + const getRes = await getPlugin(res.data.id); + await deletePlugin(res.data.id); + expect(getRes.data.status).toBe(PluginStatus.Published); + }); + + it('/api/admin/plugin/center/list (GET)', async () => { + const res = await createPlugin(mockPlugin); + await submitPlugin(res.data.id); + await publishPlugin(res.data.id); + const getRes = await getPluginCenterList(); + await deletePlugin(res.data.id); + + expect(getRes.data).toHaveLength(2); + const plugin = getRes.data.find((p) => p.id === res.data.id); + expect(plugin).not.toBeUndefined(); + expect(getPluginCenterListVoSchema.safeParse(getRes.data).success).toBe(true); + }); +}); diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json index 1875d2bde..bb3bb1e02 100644 --- a/apps/nextjs-app/package.json +++ b/apps/nextjs-app/package.json @@ -132,6 +132,7 @@ "express": "4.19.1", "filesize": "10.1.1", "fuse.js": "7.0.0", + "github-markdown-css": "5.6.1", "i18next": "23.10.1", "is-port-reachable": "3.1.0", "jschardet": "3.1.3", @@ -145,6 +146,7 @@ "next-seo": "6.5.0", "next-transpile-modules": "10.0.1", "nprogress": "0.2.0", + "penpal": "6.2.2", "picocolors": "1.0.0", "qrcode.react": "3.1.0", "react": "18.2.0", @@ -168,6 +170,7 @@ "recharts": "2.12.3", "reconnecting-websocket": "4.4.0", "reflect-metadata": "0.2.1", + "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "sharedb": "4.1.2", "tailwind-scrollbar": "3.1.0", diff --git a/apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx b/apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx deleted file mode 100644 index bfcf889dd..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { BaseQueryColumnType, type IBaseQueryVo } from '@teable/openapi'; -import { SelectTrigger } from '@teable/ui-lib'; -import { - Label, - Select, - SelectContent, - SelectItem, - SelectValue, - type ChartConfig, -} from '@teable/ui-lib/shadcn'; -import { useMemo, useState } from 'react'; -import { ChartBar } from './chart-show/Bar'; - -export const ChartDisplay = (props: { data: IBaseQueryVo }) => { - const { data } = props; - const { rows, columns } = data; - - const [xAxis, setXAxis] = useState(); - const [yAxis, setYAxis] = useState(); - const [group, setGroup] = useState(); - const yColumns = columns.filter( - (column) => - column.type === BaseQueryColumnType.Aggregation || - (column.fieldSource && - column.fieldSource?.cellValueType === 'number' && - !column.fieldSource.isMultipleCellValue) - ); - - const chartConfig = useMemo(() => { - const column = columns.find((column) => column.column === yAxis); - if (!column) return; - if (!group) { - return { - [column.column]: { - label: column.name, - color: 'hsl(var(--chart-1))', - }, - }; - } - if (!xAxis) return; - const chartConfig: ChartConfig = {}; - rows.forEach((row) => { - const groupValue = row[group] as string; - if (!chartConfig[groupValue]) { - chartConfig[groupValue] = { - label: groupValue, - color: `hsl(var(--chart-${Object.keys(chartConfig).length + 1}))`, - }; - } - }); - return chartConfig; - }, [columns, group, rows, yAxis, xAxis]); - - const convertRows = useMemo(() => { - if (!chartConfig || !group || !xAxis || !yAxis) return rows; - const xAxisColumn = columns.find((column) => column.column === xAxis); - if (!xAxisColumn) return rows; - const rowsMap: Record> = {}; - - rows.forEach((row) => { - const groupValue = row[group] as string; - const key = row[xAxis] as string; - const existRow = rowsMap[key]; - if (existRow) { - rowsMap[key] = { - ...existRow, - [groupValue]: row[yAxis], - }; - } else { - rowsMap[key] = { - [xAxis]: row[xAxis], - ...Object.keys(chartConfig).reduce( - (pre, cur) => { - pre[cur] = groupValue === cur ? (row[yAxis] as number) : 0; - return pre; - }, - {} as Record - ), - }; - } - }); - return Object.values(rowsMap); - }, [chartConfig, columns, group, rows, xAxis, yAxis]); - - return ( -
-
-
- - -
-
- - -
-
- - -
-
- {chartConfig && xAxis && ( - - )} -
- ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx b/apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx deleted file mode 100644 index 2f19e5330..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { IGetBaseVo, ITableVo } from '@teable/openapi'; -import { SessionProvider } from '@teable/sdk'; -import type { IUser } from '@teable/sdk'; -import { AnchorContext, AppProvider, BaseProvider, TableProvider } from '@teable/sdk/context'; -import { useRouter } from 'next/router'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { AppLayout } from '@/features/app/layouts'; -import { useSdkLocale } from '../../hooks/useSdkLocale'; -import { BasePermissionListener } from '../base/BasePermissionListener'; - -export const ChartLayout: React.FC<{ - children: React.ReactNode; - tableServerData: ITableVo[]; - baseServerData: IGetBaseVo; - user?: IUser; -}> = ({ children, tableServerData, baseServerData, user }) => { - const router = useRouter(); - const { baseId } = router.query; - const sdkLocale = useSdkLocale(); - const { i18n } = useTranslation(); - - return ( - - - - - - - -
- {children} -
-
-
-
-
-
-
- ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx b/apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx deleted file mode 100644 index 680d72fbe..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { baseQuery, type IBaseQuery } from '@teable/openapi'; -import type { IBaseQueryBuilderRef } from '@teable/sdk/components'; -import { BaseQueryBuilder } from '@teable/sdk/components'; -import { useBaseId } from '@teable/sdk/hooks'; -import { Button } from '@teable/ui-lib'; -import { useRef, useState } from 'react'; -import { ChartDisplay } from './ChartDisplay'; - -export const ChartPage = () => { - const [query, setQuery] = useState(); - const baseId = useBaseId(); - const queryBuilderRef = useRef(null); - - const { mutate: baseQueryMutate, data } = useMutation({ - mutationFn: ({ baseId, query }: { baseId: string; query: IBaseQuery }) => - baseQuery(baseId, query), - }); - - return ( -
-

Chart Page

-
- -
- - {data?.data && } -
- ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx b/apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx deleted file mode 100644 index aae809ce9..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - Card, - CardContent, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from '@teable/ui-lib'; -import type { ChartConfig } from '@teable/ui-lib'; -import { useState } from 'react'; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import type { Payload } from 'recharts/types/component/DefaultLegendContent'; - -export const ChartBar = (props: { - chartData: Record[]; - chartConfig: ChartConfig; - xAxis: string; -}) => { - const { chartData, chartConfig, xAxis } = props; - - const [hoverLegend, setHoverLegend] = useState(); - const [hiddenLegends, setHiddenLegends] = useState([]); - - const handleLegendMouseEnter = (o: Payload) => { - const { dataKey } = o; - setHoverLegend(dataKey as string); - }; - - const handleLegendMouseLeave = () => { - setHoverLegend(undefined); - }; - - const handleLegendClick = (o: Payload) => { - const { dataKey } = o; - if (hiddenLegends.includes(dataKey as string)) { - setHiddenLegends(hiddenLegends.filter((legend) => legend !== dataKey)); - } else { - setHiddenLegends([...hiddenLegends, dataKey as string]); - } - }; - - return ( - - - - - - - - } - /> - } /> - {Object.keys(chartConfig).map((key) => ( - - ))} - - - - - ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppDetailLayout.tsx b/apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx similarity index 69% rename from apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppDetailLayout.tsx rename to apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx index 2d050af9d..8da76780d 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppDetailLayout.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx @@ -1,19 +1,18 @@ import { Spin } from '@teable/ui-lib/base'; import { Button, Separator } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; -import { oauthAppConfig } from '@/features/i18n/oauth-app.config'; -interface IOAuthAppDetailLayoutProps { +interface IFormPageLayoutProps { children: React.ReactNode | React.ReactNode[] | null; loading?: boolean; onSubmit?: () => void; onCancel?: () => void; } -export const OAuthAppDetailLayout = (props: IOAuthAppDetailLayoutProps) => { +export const FormPageLayout = (props: IFormPageLayoutProps) => { const { children, onCancel, onSubmit, loading } = props; - const { t } = useTranslation(oauthAppConfig.i18nNamespaces); + const { t } = useTranslation('common'); return (
@@ -21,11 +20,11 @@ export const OAuthAppDetailLayout = (props: IOAuthAppDetailLayoutProps) => {
diff --git a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx index 1f3d54226..ff5f78dcf 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'next-i18next'; import { useRef, useState } from 'react'; import { CopyButton } from '@/features/app/components/CopyButton'; import { oauthAppConfig } from '@/features/i18n/oauth-app.config'; -import { OAuthAppDetailLayout } from './OAuthAppDetailLayout'; +import { FormPageLayout } from '../../components/FormPageLayout'; import type { IOAuthAppFormRef } from './OAuthAppForm'; import { OAuthAppForm } from './OAuthAppForm'; @@ -58,7 +58,7 @@ export const OAuthAppEdit = (props: IOAuthAppEditProps) => { }); return ( - { @@ -154,6 +154,6 @@ export const OAuthAppEdit = (props: IOAuthAppEditProps) => { {!queryLoading && ( )} - + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx index cbf0904f9..ab6315550 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { oauthCreate, type OAuthCreateRo } from '@teable/openapi'; import { useRef, useState } from 'react'; -import { OAuthAppDetailLayout } from './OAuthAppDetailLayout'; +import { FormPageLayout } from '../../components/FormPageLayout'; import type { IOAuthAppFormRef } from './OAuthAppForm'; import { OAuthAppForm } from './OAuthAppForm'; @@ -25,7 +25,7 @@ export const OAuthAppNew = (props: IOAuthAppNewProps) => { }, }); return ( - { formRef.current?.validate() && mutate(form); @@ -33,6 +33,6 @@ export const OAuthAppNew = (props: IOAuthAppNewProps) => { loading={isLoading} > - + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx b/apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx new file mode 100644 index 000000000..3f17a3500 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx @@ -0,0 +1,48 @@ +import { useTheme } from '@teable/next-themes'; +import { Tabs, TabsContent, TabsList, TabsTrigger, Textarea } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useEffect } from 'react'; +import { MarkdownPreview } from '@/features/app/components/MarkdownPreview'; +import { settingPluginConfig } from '@/features/i18n/setting-plugin.config'; + +export const MarkDownEditor = (props: { + defaultStatus?: 'write' | 'preview'; + value?: string; + onChange: (value: string) => void; +}) => { + const { defaultStatus = 'write', value, onChange } = props; + const { resolvedTheme: currentTheme } = useTheme(); + const { t } = useTranslation(settingPluginConfig.i18nNamespaces); + + useEffect(() => { + if (currentTheme === 'dark') { + require('github-markdown-css/github-markdown-dark.css'); + } else { + require('github-markdown-css/github-markdown-light.css'); + } + }, [currentTheme]); + return ( +
+ + + + {t('plugin:markdown.write')} + + + {t('plugin:markdown.preview')} + + + +