Skip to content

Commit

Permalink
feat: chart plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
boris-w committed Sep 12, 2024
1 parent d287d8e commit 70a135b
Show file tree
Hide file tree
Showing 82 changed files with 4,199 additions and 12 deletions.
1 change: 1 addition & 0 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,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",
Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/configs/base.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const baseConfig = registerAs('base', () => ({
publicDatabaseProxy: process.env.PUBLIC_DATABASE_PROXY,
defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20),
templateSpaceId: process.env.TEMPLATE_SPACE_ID,
pluginServerPort: process.env.PLUGIN_SERVER_PORT || '3002',
}));

export const BaseConfig = () => Inject(baseConfig.KEY);
Expand Down
1 change: 0 additions & 1 deletion apps/nestjs-backend/src/features/next/next.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/nestjs-backend/src/features/next/next.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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://127.0.0.1:${baseConfig.pluginServerPort}`,
});
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async use(req: Request, res: Response, next: () => void): Promise<any> {
this.proxy(req, res, next);
}
}
Original file line number Diff line number Diff line change
@@ -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/?*',
});
}
}
8 changes: 8 additions & 0 deletions apps/nestjs-backend/src/features/next/plugin/plugin.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PluginProxyModule } from './plugin-proxy.module';
@Module({
imports: [PluginProxyModule],
providers: [],
controllers: [],
})
export class NextPluginModule {}
191 changes: 191 additions & 0 deletions apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<IPluginGetTokenVo> {
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<IPluginRefreshTokenVo> {
const { secret, refreshToken } = ro;
const plugin = await this.validateSecret(secret, pluginId);
const payload = await this.jwtService.verifyAsync<IRefreshPayload>(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;
}
}
40 changes: 39 additions & 1 deletion apps/nestjs-backend/src/features/plugin/plugin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
IGetPluginCenterListVo,
IGetPluginsVo,
IGetPluginVo,
IPluginGetTokenVo,
IPluginRefreshTokenVo,
IPluginRegenerateSecretVo,
IUpdatePluginVo,
} from '@teable/openapi';
Expand All @@ -14,13 +16,24 @@ import {
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) {}
constructor(
private readonly pluginService: PluginService,
private readonly pluginAuthService: PluginAuthService
) {}

@Post()
createPlugin(
Expand Down Expand Up @@ -68,4 +81,29 @@ export class PluginController {
submitPlugin(@Param('pluginId') pluginId: string): Promise<void> {
return this.pluginService.submitPlugin(pluginId);
}

@Post(':pluginId/token')
@Public()
accessToken(
@Param('pluginId') pluginId: string,
@Body(new ZodValidationPipe(pluginGetTokenRoSchema)) ro: IPluginGetTokenRo
): Promise<IPluginGetTokenVo> {
return this.pluginAuthService.token(pluginId, ro);
}

@Post(':pluginId/refreshToken')
@Public()
refreshToken(
@Param('pluginId') pluginId: string,
@Body(new ZodValidationPipe(pluginRefreshTokenRoSchema)) ro: IPluginRefreshTokenRo
): Promise<IPluginRefreshTokenVo> {
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<string> {
return this.pluginAuthService.authCode(pluginId, baseId);
}
}
20 changes: 18 additions & 2 deletions apps/nestjs-backend/src/features/plugin/plugin.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
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 { UserModule } from '../user/user.module';
import { PluginAuthService } from './plugin-auth.service';
import { PluginController } from './plugin.controller';
import { PluginService } from './plugin.service';

@Module({
imports: [UserModule],
providers: [PluginService],
imports: [
UserModule,
AccessTokenModule,
JwtModule.registerAsync({
useFactory: (config: IAuthConfig) => ({
secret: config.jwt.secret,
signOptions: {
expiresIn: config.jwt.expiresIn,
},
}),
inject: [authConfig.KEY],
}),
],
providers: [PluginService, PluginAuthService],
controllers: [PluginController],
})
export class PluginModule {}
Loading

0 comments on commit 70a135b

Please sign in to comment.